asterism branch worktree-legacy-cleanup commits 3 files 60 touched lines +536 / -7256 core suite 1157 checks, 0 issues

Pre-push review: worktree-legacy-cleanup

Three commits retiring dead migration, backup-import and performance-fixture code — 7,256 lines deleted against 536 added. Reviewed by four parallel agents (reuse, quality, correctness, documentation).

At a glance

  • 7,256 lines deleted, 536 added across 60 files — the V1 migration island, the 2/2/3/3 backup import paths, and the M2/M3 scale-performance apparatus.
  • The SwiftData version chain was deliberately left alone. AsterismSchemaV5.swift records both failure modes from trimming it; retiring the inert runtime passes is tracked separately as T-2114.
  • A live guard had lost every test. DuplicateJSONKeyValidator is the only thing rejecting a duplicate JSON key on the import path, and all three of its tests were in deleted files. Restored, and proven load-bearing by mutation testing.
  • Two tickets filed rather than scope-crept: T-2113 (AsterismSchemaV2 is a mislabelled alias for the live V5 models) and T-2114 (retire the migration runtime passes).

Verdict

Ready to push

No blockers. Four reviewers independently verified the three highest-risk moves — the FrozenV3TitleInterpretation relocation, the two codec helpers rescued from a deleted file, and the hand-rewritten makeMinimalImportPlan fixture — as byte-exact or field-equivalent. Three majors were raised and all three are fixed: a changelog that promised the import support this branch removed, a live JSON validator that had silently lost all of its test coverage, and a user-facing error string naming the wrong archive format.

One consequence is deliberate and irreversible in-app, and is now stated in the changelog: a pre-M3.5 2/2 or 3/3 backup archive can no longer be restored without checking out an older build.

Review findings

14 raised · 11 fixed · 3 skipped

Jump to findings →

Three-level explanation

What changed

This branch deletes code the app no longer runs. Nothing a reader of the app can see changes — no new screens, no changed behaviour when capturing or reading notes.

Three things were removed. First, the tooling that upgraded a library from the app's very first storage format — every library has long since been upgraded, and the tool could not even be reached from the running app any more. Second, the ability to read the two oldest backup file formats; the app now reads only the current one. Third, a set of test fixtures that generated 20,000 fake entries to measure performance, whose only user had already been deleted.

Why it matters

Dead code is not free. It still compiles, still needs to be read and understood by anyone working nearby, and can still mislead — several comments in this repo described a world that no longer existed.

The one thing to know

If you are holding a backup file made before the “unified teaching” release, the app can no longer restore it. You would have to install an older build, restore it there, and export a fresh copy. This is a deliberate trade the repo owner asked for; it is now written down in the changelog where a user would look.

Shape of the change

Three commits, 60 files, a 13:1 deletion-to-addition ratio. The removals fall into three clusters, each verified to be closed — that is, held up only by other members of the same cluster.

  • The V1 migration island: the AsterismV1MigrationSupport package target, the AsterismMigrationTool executable, V2MigrationStore, LibraryConfiguration.legacyStoreURL and the make migrate-m1-to-m2 target. The decisive evidence: the only guard that would have routed a V1 store to the tool lived in openCurrent, which the app and share extension stopped calling when they moved to openV4ForApp/openV4ForExtension.
  • Old-format backup import: BackupImporter.planV4 accepted (2,2), (3,3) and (4,4); it now accepts only (4,4). Both codecs, both mappers and the frozen 3/3 reference validator went with it.
  • M2/M3 scale fixtures: orphaned once the device performance target and its UI test were removed.

What had to be rescued

Deletion by reference-count is not safe on its own — three pieces of live code were embedded in files being removed. LegacyV2DateFormatter (since renamed BackupArchiveDateFormatter) and DuplicateJSONKeyValidator are used by the current V4 codec and were extracted into BackupJSONCodecSupport.swift. FrozenV3TitleInterpretation is read by the still-live V3→V4 migration and moved into V4Migration.swift. The compiler caught all three; a pure grep-and-delete would not have.

What was deliberately kept

The [V3, V4, V5] SwiftData migration plan and both frozen schema snapshots stay, even though no store should still need them. AsterismSchemaV5.swift records two measured failure modes from trimming that chain, so the payoff (inert code) does not justify the risk (an unopenable library).

The interesting failure mode this review caught

BackupV4Codec.decode runs two validators in sequence: DuplicateJSONKeyValidator.validate, then BackupV4ShapeValidator.validate. The second goes through JSONSerialization, which resolves duplicate object keys silently. The first is therefore the only thing standing between a document carrying two payload keys and a decode that imports the wrong one — and its entire test coverage lived in the format-2 and format-3 codec suites this branch deleted.

The surviving V4 strictness tests could not have caught the regression: they mutate through a mutatingRoot helper that round-trips the document through JSONSerialization, so they are structurally incapable of expressing a duplicate key or trailing bytes. The gap was invisible to test-count and coverage heuristics alike.

The ported tests edit the encoded bytes directly. They were then verified by mutation: commenting out the DuplicateJSONKeyValidator.validate call, the duplicate-key document decodes successfully and returns the injected appBuild: "shadow". All three new tests fail. The coverage is load-bearing rather than decorative.

Equivalence of the rewritten fixture

makeMinimalImportPlan previously built a BackupV3Payload and ran it through V3ToV4BackupMapper; it now constructs a BackupV4Payload literal. Two reviewers independently diffed the mapper field-by-field against the literal. The mapper derived exactly three things — conservativeIdentityKey from rawURL, nil identityNameTitleRule*, nil pattern trims — all of which match. The one genuinely conditional branch (synthesizing a whole-title pattern when site.titleInterpretation == .wholeCaptureTitle) never fired for this fixture, whose Site is .pattern, so patternIDs and the record counts are unchanged.

Error-type provenance

BackupCodecError outlived the format-2 codec that defined it, because DuplicateJSONKeyValidator throws it. That made a V4 decode failure surface to the reader as “Duplicate key in Backup V2” through BackupImportError.decodingFailedSettingsBackupImportModelSettingsBackupImportView. The four cases whose only throwers were in the deleted codec are now gone, and the remaining messages are archive-generic. A recognised 2/2/3/3 envelope gets its own explanatory refusal rather than falling through to the “expected 4/4” default.

Important changes — detailed

BackupJSONCodecSupport: two live helpers rescued from a deleted file

Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift

Why it matters. BackupV4Codec — the current, only archive codec — depends on both. They lived inside LegacyBackupV2Codec.swift, which the branch deletes. A reference-count-driven deletion would have taken working code with it; the compiler is what caught this.

What to look at. BackupJSONCodecSupport.swift:1-165 (BackupArchiveDateFormatter, DuplicateJSONKeyValidator)

Takeaway. On a deletion branch, 'this file is only referenced by other files I am deleting' is not the same as 'nothing in this file is live'. Check symbol-level reachability, not file-level, and let the compiler arbitrate before trusting the grep.
Rationale. Extracted verbatim rather than reimplemented so the live archive format could not drift. Two reviewers independently diffed them against origin/main and confirmed byte-identity apart from doc comments.

BackupV4CodecTests: restore duplicate-key and trailing-byte coverage

Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swift

Why it matters. DuplicateJSONKeyValidator is the sole guard for both properties on the live decode path, and all three of its tests were in deleted files. Without this, stubbing the validator would pass every remaining backup test while decoding silently reverted to last-key-wins — an archive with two `payload` keys would import the wrong one.

What to look at. BackupV4CodecTests.swift:139-205 (three byte-level tests plus a fractional-second round-trip)

Takeaway. When a test helper normalises its input, it silently bounds what the suite can express. `mutatingRoot` round-trips through JSONSerialization, so no test written on top of it could ever construct a duplicate key — the coverage gap was invisible to test counts and to line coverage alike. Reach for raw bytes when the property under test is about the encoding itself.
Rationale. Verified by mutation rather than assumed: with the validator call commented out, the duplicate-key document decodes clean and returns the injected appBuild, and all three tests fail. A test that cannot fail is not coverage.

BackupCodecError: stop saying "Backup V2" on a V4 failure

Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift

Why it matters. The error outlived the codec that named it. Its last thrower is the duplicate-key validator, reached from BackupV4Codec.decode, and the text runs all the way to SettingsBackupImportView — so importing a corrupt current-format archive reported a format the app no longer supports.

What to look at. BackupV2Types.swift:212-240

Takeaway. Error enums outlive their original throwers more often than types do, because they are thrown from shared helpers. When retiring a subsystem, follow the error type's call graph to the UI, not just its declaration site.
Rationale. Reworded archive-generically rather than renamed wholesale: the type still lives in a V2-named file whose other contents are genuinely live, and splitting that file belongs to T-2113.

BackupImporter: 4/4 only, with an honest refusal for recognised old formats

Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift

Why it matters. This is the branch's one user-visible consequence. A 2/2 or 3/3 archive that restored fine last release is now declined, and the reader has done nothing wrong — so the message distinguishes 'too old to restore' from 'not a backup'.

What to look at. BackupImporter.swift:108-135

Takeaway. When you drop support for an input you used to accept, keep the discriminator that recognises it. Deleting the parse path but keeping the version check lets you say 'this is the thing, and it is too old' instead of 'unrecognised'.
Rationale. The repo owner accepted losing in-place restore of pre-M3.5 archives. The recovery path — check out a build at or before 52c6504, restore, re-export at 4/4 — is now recorded in the changelog's Upgrading section.

The SwiftData migration chain was deliberately NOT trimmed

Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift

Why it matters. The obvious next deletion — the [V3, V4, V5] plan and its frozen snapshots — is the one that would brick the store. Both failure modes are already measured and recorded in AsterismSchemaV5.swift:26-42.

What to look at. V4Migration.swift:1-16 (FrozenV3TitleInterpretation, moved from the deleted BackupV3Types)

Takeaway. SwiftData's staged migration needs the version chain to be structurally real, not merely historically accurate. Freezing V4 as a copy of V5 aborts with 'Duplicate version checksums detected'; dropping the V4→V5 stage fails the open with NSCocoaErrorDomain 134504. Inert is not the same as removable.
Rationale. Scoped out to T-2114, which requires probing whether a V5-recorded store opens under a [V5]-only plan before touching the snapshots — an unproven assumption the ticket names explicitly rather than inheriting.

Key decisions

Keep the [V3, V4, V5] migration plan despite every store being at V5.

The repo owner overruled the audit's original ‘keep as insurance’ recommendation for the runtime passes, on the grounds that legacy code kept for a case that cannot recur is a hazard rather than a safety net. That decision is recorded as T-2114. The schema chain is a different question and stays: it is load-bearing for the store open, with two measured failure modes on record.

Retire 2/2 and 3/3 import rather than keeping read-only support.

Explicitly requested. The alternative — keeping the codecs but not the mappers — buys nothing, since a decoded old payload is unusable without the mapping. The cost is stated plainly in the changelog rather than softened.

Rename LegacyV2DateFormatter, but not M2PerformanceSignposts.

The formatter is internal with three call sites, so renaming is mechanical and risk-free. M2PerformanceSignposts was left alone despite the same misnomer: its category string is the contract the device measurement reads, and that measurement cannot be run here to verify a rename. A cosmetic fix is not worth silently breaking a measurement nobody can check.

Leave specs/ and CHANGELOG history alone; annotate rather than rewrite.

Spec files record what was true when a milestone shipped. Rewriting them to match today's code would falsify the record. Where a spec statement reads as a live contract rather than history — cloudkit-mirroring Req 4.6, the T-1947 carried-forward item, an unticked device-safety checkbox naming deleted targets — the OVERVIEW or the file itself now carries a dated correction instead.

Rewrite the minimal import fixture as a V4 literal instead of parameterising BackupV4Fixtures.

Reuse would have meant adding a hostname, UUID-freshness, optional-URL-rule and three-way perturbation parameters to the shared fixture. The reviewer flagged the duplication as a minor and judged the parameterisation not worth it unless a third builder appears. Recorded so the next person meeting both builders knows it was considered.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorCHANGELOG.md:27 — Unreleased/UpgradingTold users "Existing 2/2 and 3/3 backups still import" — false as of this branch, in the one place a user would look before deciding whether to re-export an old archive. Raised independently by two reviewers.Corrected the bullet to state that 4/4 is now the only importable format, with the recovery path, and added a Removed section covering all three removals.
majorBackupV4CodecTests — coverageDuplicate-key and trailing-byte rejection lost all test coverage. DuplicateJSONKeyValidator is live and is the only guard for both, since BackupV4ShapeValidator goes through JSONSerialization which collapses duplicates silently. Its three tests were all in deleted files, and the surviving V4 tests cannot express either fault.Ported three byte-level tests plus a fractional-second round-trip. Verified by mutation: with the validator bypassed, the duplicate-key document decodes clean and all three fail.
majorBackupV2Types.swift:213-240 — user-facing textBackupCodecError's messages all said "Backup V2", and the error reaches the import screen on a V4 decode failure. Four of its ten cases were also newly unreachable.Reworded archive-generically; deleted the four cases whose only throwers were in the removed format-2 codec.
majorM3ScaleFixture.swift508-line fixture fully orphaned — its only consumer, M3ScaleFixtureTests, was deleted in the first commit. The M2 sweep missed the M3 sibling. Raised by two reviewers.Deleted.
minorBackupImportTransactionTests.swift:240-260Two tests built JSON with the key "formatVersion", but detectVersions reads "backupFormatVersion" — so both only ever exercised the missing-key branch, never the version-pair logic they name. One title also still said "other than 2/2 or 3/3".Fixed the key so they test what they claim, corrected the title, and left a comment recording the trap.
minorBackupImporter.swift:108-135 — refusal messageA user restoring a 2/2 or 3/3 archive got "format 3/schema 3 is not supported; expected 4/4" — internal version numbers and no guidance, for a file that worked last release.Recognised old formats now get an explanatory refusal naming the format and saying a recent-version backup is required.
minordocs/agent-notes/schema-migration.md:53Claimed AsterismSchemaV2 is "used by ~18 test files" — it is four. The figure came from grepping the bare name openForApp, which mostly matches same-named test-local helpers that call openV4ForApp. This oversized the T-2113 cleanup.Corrected to four, named them, and recorded the grep trap so the next reader does not repeat it.
minorspecs/library-integrity-tolerance/prerequisites.md:10An unticked device-safety checklist item named `make test-performance` and `-m3` as the targets to guard before a device run. Both are gone. Given the incident that file exists to prevent, a safety checklist pointing at non-existent targets is worth correcting.Points at the one surviving device target and defers to CLAUDE.md for the current list.
minorspecs/OVERVIEW.md — stale live claimscloudkit-mirroring Req 4.6 still asserts 2/2, 3/3 and 4/4 all import; the T-1947 carried-forward item still read as an open TODO for work this branch completed.Both annotated with dated corrections; the milestone records themselves left intact as history.
minorDoc comments naming deleted typesAsterismCapabilities, V2LibraryValidator, LibraryRepository.validateStore, BackupV4Codec, BackupV4Types and Models.Site.urlIdentityRule all carried comments referencing BackupV3Codec, the deleted mappers, or a legacy-mapping plan that will never run.All rewritten to describe the current state, including a warning on BackupV4ShapeValidator that decode must keep running the duplicate-key validator first.
nitWhitespace and wrappingTriple blank line in BackupJSONCodecSupport.swift, double blanks in BackupV2Types.swift and the Makefile where excisions landed, and a 108-char line in an 80-column CLAUDE.md paragraph.Cleaned up.
minorLibraryBackupSnapshot stack — ~450 linesRemoving backupSnapshot() left the snapshot record types, V2LibraryValidator and four map*Record helpers reachable only from the AsterismSchemaV2 test-store opener, which no production code calls.Not fixed here. Removing it means unpicking validateStore, which four test files depend on through openForApp — the same entanglement T-2113 exists to resolve. Folded into that ticket rather than scope-crept into a cleanup branch.
nitM2PerformanceSignposts namingThe type keeps an M2 name with no M2 consumers, and the new signpost test pins "M2Performance" as a contract, entrenching the misnomer.Not fixed. The category string is the contract the physical-device measurement reads, and that measurement cannot be run here to verify a rename. Cosmetic gain, unverifiable risk.
nitmake test-performance-m4 runtimeQuoted as ~40 minutes in CLAUDE.md, ~30 in the Makefile, ~35 in docs/agent-notes/testing.md.Not fixed — pre-existing, and settling it honestly means a 30-40 minute measured run rather than picking a number. Noted for whoever runs it next.

Per-file diffs

Click to expand.

Asterism/AsterismUITests/M2ScalePerformanceUITests.swift Deleted +0 / -110
diff --git a/Asterism/AsterismUITests/M2ScalePerformanceUITests.swift b/Asterism/AsterismUITests/M2ScalePerformanceUITests.swiftdeleted file mode 100644index 0c9db30..0000000--- a/Asterism/AsterismUITests/M2ScalePerformanceUITests.swift+++ /dev/null@@ -1,110 +0,0 @@-import UIKit-import XCTest--/// Requirement 10's Recent measurement over the **20,000-Entry M2 fixture**.-///-/// The measurement runs through `make test-performance` on a physical iPhone;-/// normal simulator and CI suites skip it. The scale launch scenario resets a-/// fresh fixture for every iteration so measurements never reuse mutated state.-///-/// `testSeededScaleM2ScenarioReachesRecent` is deliberately **not** device-gated:-/// it runs on every `make test-ui`. Both measurements in this file were dead for-/// two milestones because `seedM2PerformanceFixture` threw on its capability-/// guard and the only symptom was a `waitForExistence` timeout inside a-/// device-only test nobody ran. A seeder that throws has to fail on the-/// simulator.-///-/// The M4 milestone removed the teaching surface this suite's second measurement-/// drove (`segment-chip-0`, `teaching-preview`, and the-/// `TeachingFinalPreviewPublication` signpost, none of which exist any more), so-/// that test is gone. Recent stays: `RecentPublication` is still emitted, and the-/// 20,000-Entry M2 graph is a different measurement from the 5,000-Entry M4 one-/// in `M4ScaleRecentPerformanceUITests`.-final class M2ScalePerformanceUITests: XCTestCase {-    private static let subsystem = "me.nore.ig.Asterism"-    private static let category = "M2Performance"-    private static let recentPublication = "RecentPublication"-    private static let scenario = "seeded-scale-m2"-    /// Seeding 20,000 Entries and 2,000 Works costs more on a debug simulator-    /// build than on the optimized device build the measurement uses.-    private static let seedTimeout: TimeInterval = 180--    override func setUpWithError() throws {-        continueAfterFailure = false-    }--    /// Proves the scenario seeds and the app reaches Recent on it. Runs-    /// everywhere, including the simulator.-    @MainActor-    func testSeededScaleM2ScenarioReachesRecent() throws {-        let app = launchFreshScaleFixture()-        XCTAssertTrue(-            app.collectionViews["recent-list"].waitForExistence(timeout: Self.seedTimeout),-            "the seeded-scale-m2 scenario never reached Recent — check that the seed did not throw"-        )-        app.terminate()-    }--    @MainActor-    func testRecentPublicationSignpostAtSupportedScale() throws {-        try requirePhysicalMeasurementEnvironment()--        let metric = XCTOSSignpostMetric(-            subsystem: Self.subsystem,-            category: Self.category,-            name: Self.recentPublication-        )-        let options = XCTMeasureOptions()-        options.iterationCount = 20--        try warmUpRecent()-        measure(metrics: [metric], options: options) {-            let app = launchFreshScaleFixture()-            XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: Self.seedTimeout))-            app.terminate()-        }-    }--    // MARK: - Helpers--    private func requirePhysicalMeasurementEnvironment() throws {-        guard ProcessInfo.processInfo.environment["ASTERISM_RUN_PHYSICAL_PERFORMANCE"] == "1" else {-            throw XCTSkip("Set ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 via make test-performance")-        }-        #if targetEnvironment(simulator)-        throw XCTSkip("Requirement 10 measurements must run on a physical iPhone")-        #else-        guard !ProcessInfo.processInfo.isLowPowerModeEnabled else {-            throw XCTSkip("Disable Low Power Mode before measuring")-        }-        guard ProcessInfo.processInfo.thermalState == .nominal else {-            throw XCTSkip("Wait for nominal thermal state before measuring")-        }--        let device = UIDevice.current-        add(-            XCTAttachment(-                string: "Device: \(device.model); system: \(device.systemName) \(device.systemVersion)"-            )-        )-        #endif-    }--    @MainActor-    private func warmUpRecent() throws {-        let app = launchFreshScaleFixture()-        XCTAssertTrue(app.collectionViews["recent-list"].waitForExistence(timeout: Self.seedTimeout))-        app.terminate()-    }--    /// A fresh run identifier per launch, so every iteration seeds its own-    /// disposable library and no measurement reuses mutated state.-    @MainActor-    private func launchFreshScaleFixture() -> XCUIApplication {-        let app = XCUIApplication()-        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = Self.scenario-        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString-        app.launch()-        return app-    }-}
Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swift Deleted +0 / -383
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swiftdeleted file mode 100644index 9204e51..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Codec.swift+++ /dev/null@@ -1,383 +0,0 @@-import Foundation--internal enum BackupV2CodecImplementation {-    static func encode(-        snapshot: LibraryBackupSnapshot,-        metadata: BackupMetadata,-        capabilities: AsterismCapabilities-    ) throws -> Data {-        guard metadata.databaseSchemaVersion == BackupV2Document.schemaVersion else {-            throw BackupCodecError.invalidSchemaVersion(metadata.databaseSchemaVersion)-        }-        do {-            try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)-            return try JSONSerialization.data(-                withJSONObject: BackupV2JSONWriter.document(-                    snapshot: snapshot,-                    metadata: metadata,-                    capabilities: capabilities-                ),-                options: [.sortedKeys, .withoutEscapingSlashes]-            )-        } catch let error as BackupValidationError {-            throw error-        } catch let error as BackupCodecError {-            throw error-        } catch {-            throw BackupCodecError.encodingFailed(reason: String(describing: error))-        }-    }--    static func decode(-        _ data: Data,-        capabilities: AsterismCapabilities-    ) throws -> BackupV2Document {-        do {-            try DuplicateJSONKeyValidator.validate(data)-            let object = try JSONSerialization.jsonObject(with: data)-            try BackupV2ShapeValidator.validate(object)--            let decoder = JSONDecoder()-            decoder.dateDecodingStrategy = .custom(decodeDate)-            let document = try decoder.decode(BackupV2Document.self, from: data)-            guard document.backupFormatVersion == BackupV2Document.formatVersion else {-                throw BackupCodecError.invalidFormatVersion(document.backupFormatVersion)-            }-            guard document.databaseSchemaVersion == BackupV2Document.schemaVersion else {-                throw BackupCodecError.invalidSchemaVersion(document.databaseSchemaVersion)-            }-            guard document.capabilityGate == capabilities.gate else {-                throw BackupCodecError.capabilityMismatch(-                    expected: capabilities.gate,-                    actual: document.capabilityGate-                )-            }-            try V2LibraryValidator.validate(snapshot: document.payload, capabilities: capabilities)-            return document-        } catch let error as BackupCodecError {-            throw error-        } catch let error as BackupValidationError {-            throw error-        } catch {-            throw BackupCodecError.decodingFailed(reason: String(describing: error))-        }-    }--    private static func decodeDate(_ decoder: Decoder) throws -> Date {-        let container = try decoder.singleValueContainer()-        let value = try container.decode(String.self)-        guard let date = BackupV2DateFormatter.date(from: value) else {-            throw DecodingError.dataCorruptedError(-                in: container,-                debugDescription: "date must be RFC 3339 UTC with milliseconds"-            )-        }-        return date-    }-}--private enum BackupV2JSONWriter {-    static func document(-        snapshot: LibraryBackupSnapshot,-        metadata: BackupMetadata,-        capabilities: AsterismCapabilities-    ) -> [String: Any] {-        [-            "backupFormatVersion": BackupV2Document.formatVersion,-            "databaseSchemaVersion": metadata.databaseSchemaVersion,-            "appBuild": metadata.appBuild,-            "exportedAt": BackupV2DateFormatter.string(from: metadata.exportedAt),-            "capabilityGate": capabilities.gate.rawValue,-            "payload": [-                "entries": snapshot.entries.map(entry),-                "works": snapshot.works.map(work),-                "sites": snapshot.sites.map(site),-                "titlePatterns": snapshot.titlePatterns.map(pattern),-            ],-        ]-    }--    private static func entry(_ value: EntryRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "captureTitle": value.captureTitle,-            "captureTitleSource": value.captureTitleSource.rawValue,-            "rawURL": value.rawURL,-            "canonicalURL": nullable(value.canonicalURL),-            "hostname": value.hostname,-            "entryIdentityKey": value.entryIdentityKey,-            "identityKeyVersion": value.identityKeyVersion,-            "chapterTitle": nullable(value.chapterTitle),-            "chapterTitleProvenance": provenance(value.chapterTitleProvenance),-            "note": value.note,-            "rating": nullable(value.rating?.rawValue),-            "firstCapturedAt": BackupV2DateFormatter.string(from: value.firstCapturedAt),-            "lastSharedAt": BackupV2DateFormatter.string(from: value.lastSharedAt),-            "modifiedAt": BackupV2DateFormatter.string(from: value.modifiedAt),-            "workID": nullable(value.workID.map(uuid)),-            "workAssignmentProvenance": provenance(value.workAssignmentProvenance),-            "intentionallyUnattached": value.intentionallyUnattached,-        ]-    }--    private static func work(_ value: WorkRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "displayTitle": value.displayTitle,-            "lastParsedTitle": nullable(value.lastParsedTitle),-            "siteHostname": value.siteHostname,-            "urlIdentity": nullable(value.urlIdentity),-            "workURL": nullable(value.workURL),-            "genericNotes": value.genericNotes,-            "type": value.type.rawValue,-            "genreTags": value.genreTags,-            "titleProvenance": value.titleProvenance.rawValue,-            "createdAt": BackupV2DateFormatter.string(from: value.createdAt),-            "modifiedAt": BackupV2DateFormatter.string(from: value.modifiedAt),-            "entryIDs": value.entryIDs.map(uuid),-        ]-    }--    private static func site(_ value: SiteRecord) -> [String: Any] {-        [-            "hostname": value.hostname,-            "displayName": value.displayName,-            "mode": value.mode.rawValue,-            "patternIDs": value.patternIDs.map(uuid),-            "urlIdentityRule": nullable(value.urlIdentityRule.map(urlIdentityRule)),-            "junkSuffixRule": nullable(value.junkSuffixRule.map(junkSuffixRule)),-        ]-    }--    private static func pattern(_ value: TitlePatternRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "version": value.version,-            "isActive": value.isActive,-            "createdAt": BackupV2DateFormatter.string(from: value.createdAt),-            "definition": definition(value.definition),-            "siteHostname": value.siteHostname,-        ]-    }--    private static func definition(_ value: PatternDefinition) -> [String: Any] {-        switch value {-        case .segment(let work, let ignored):-            [-                "segment": [-                    "work": range(work),-                    "ignored": ignored.map(position),-                ],-            ]-        case .phrase(let prefix, let separator, let suffix, let order):-            [-                "phrase": [-                    "prefix": prefix,-                    "separator": separator,-                    "suffix": suffix,-                    "order": order.rawValue,-                ],-            ]-        case .chapterlessSegment, .chapterlessPhrase, .wholeTitle:-            // Frozen V2 format: M4 title-rule forms never appear in a V2 snapshot-            // and the V2 wire format is never redefined (Decision 2).-            preconditionFailure("V2 backups cannot encode M4 title-rule forms")-        }-    }--    private static func range(_ value: SegmentRangeSpec) -> [String: Any] {-        ["origin": value.origin.rawValue, "offset": value.offset, "length": value.length]-    }--    private static func position(_ value: SegmentPositionSpec) -> [String: Any] {-        ["origin": value.origin.rawValue, "offset": value.offset]-    }--    private static func provenance(_ value: FieldProvenance) -> [String: Any] {-        [-            "kind": value.kind.rawValue,-            "patternID": nullable(value.patternID.map(uuid)),-            "patternVersion": nullable(value.patternVersion),-        ]-    }--    private static func urlIdentityRule(_ value: URLIdentityRule) -> [String: Any] {-        [-            "version": value.version,-            "component": value.component.rawValue,-            "origin": nullable(value.origin?.rawValue),-            "offset": nullable(value.offset),-            "queryName": nullable(value.queryName),-        ]-    }--    private static func junkSuffixRule(_ value: JunkSuffixRule) -> [String: Any] {-        ["version": value.version, "anchors": value.anchors.map(position)]-    }--    private static func uuid(_ value: UUID) -> String {-        value.uuidString.lowercased()-    }--    private static func nullable(_ value: Any?) -> Any {-        value ?? NSNull()-    }-}--private enum BackupV2DateFormatter {-    static func string(from date: Date) -> String {-        formatter().string(from: MillisecondInstant.quantize(date))-    }--    static func date(from value: String) -> Date? {-        formatter().date(from: value)-    }--    private static func formatter() -> ISO8601DateFormatter {-        let formatter = ISO8601DateFormatter()-        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]-        formatter.timeZone = TimeZone(secondsFromGMT: 0)-        return formatter-    }-}--private enum BackupV2ShapeValidator {-    static func validate(_ value: Any) throws {-        let root = try object(value, path: "$")-        try keys(-            root,-            allowed: ["backupFormatVersion", "databaseSchemaVersion", "appBuild", "exportedAt", "capabilityGate", "payload"],-            required: ["backupFormatVersion", "databaseSchemaVersion", "appBuild", "exportedAt", "capabilityGate", "payload"],-            path: "$"-        )-        let payload = try object(root["payload"], path: "$.payload")-        try keys(payload, allowed: ["entries", "works", "sites", "titlePatterns"], required: ["entries", "works", "sites", "titlePatterns"], path: "$.payload")--        try array(payload["entries"], path: "$.payload.entries").enumerated().forEach { index, value in-            let path = "$.payload.entries[\(index)]"-            let record = try object(value, path: path)-            try keys(-                record,-                allowed: ["id", "captureTitle", "captureTitleSource", "rawURL", "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion", "chapterTitle", "chapterTitleProvenance", "note", "rating", "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID", "workAssignmentProvenance", "intentionallyUnattached"],-                required: ["id", "captureTitle", "captureTitleSource", "rawURL", "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion", "chapterTitle", "chapterTitleProvenance", "note", "rating", "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID", "workAssignmentProvenance", "intentionallyUnattached"],-                path: path-            )-            try validateProvenance(record["chapterTitleProvenance"], path: "\(path).chapterTitleProvenance")-            try validateProvenance(record["workAssignmentProvenance"], path: "\(path).workAssignmentProvenance")-        }--        try array(payload["works"], path: "$.payload.works").enumerated().forEach { index, value in-            let path = "$.payload.works[\(index)]"-            let record = try object(value, path: path)-            try keys(record, allowed: ["id", "displayTitle", "lastParsedTitle", "siteHostname", "urlIdentity", "workURL", "genericNotes", "type", "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs"], required: ["id", "displayTitle", "lastParsedTitle", "siteHostname", "urlIdentity", "workURL", "genericNotes", "type", "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs"], path: path)-        }--        try array(payload["sites"], path: "$.payload.sites").enumerated().forEach { index, value in-            let path = "$.payload.sites[\(index)]"-            let record = try object(value, path: path)-            try keys(record, allowed: ["hostname", "displayName", "mode", "patternIDs", "urlIdentityRule", "junkSuffixRule"], required: ["hostname", "displayName", "mode", "patternIDs", "urlIdentityRule", "junkSuffixRule"], path: path)-            if let urlIdentityRule = record["urlIdentityRule"], !(urlIdentityRule is NSNull) {-                let rulePath = "\(path).urlIdentityRule"-                let rule = try object(urlIdentityRule, path: rulePath)-                try keys(-                    rule,-                    allowed: ["version", "component", "origin", "offset", "queryName"],-                    required: ["version", "component", "origin", "offset", "queryName"],-                    path: rulePath-                )-            }-            if let junkSuffixRule = record["junkSuffixRule"], !(junkSuffixRule is NSNull) {-                let rulePath = "\(path).junkSuffixRule"-                let rule = try object(junkSuffixRule, path: rulePath)-                try keys(rule, allowed: ["version", "anchors"], required: ["version", "anchors"], path: rulePath)-                try array(rule["anchors"], path: "\(rulePath).anchors").enumerated().forEach { anchorIndex, anchor in-                    try validatePosition(anchor, path: "\(rulePath).anchors[\(anchorIndex)]")-                }-            }-        }--        try array(payload["titlePatterns"], path: "$.payload.titlePatterns").enumerated().forEach { index, value in-            let path = "$.payload.titlePatterns[\(index)]"-            let record = try object(value, path: path)-            try keys(record, allowed: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"], required: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"], path: path)-            let definition = try object(record["definition"], path: "\(path).definition")-            try keys(definition, allowed: ["segment", "phrase"], required: [], path: "\(path).definition")-            guard definition.count == 1 else {-                throw BackupCodecError.invalidValue(key: "\(path).definition", reason: "exactly one tagged form is required")-            }-            if let segment = definition["segment"] {-                let arm = try object(segment, path: "\(path).definition.segment")-                try keys(arm, allowed: ["work", "ignored"], required: ["work", "ignored"], path: "\(path).definition.segment")-                try validateRange(-                    arm["work"],-                    path: "\(path).definition.segment.work"-                )-                try array(-                    arm["ignored"],-                    path: "\(path).definition.segment.ignored"-                ).enumerated().forEach { ignoredIndex, position in-                    try validatePosition(-                        position,-                        path: "\(path).definition.segment.ignored[\(ignoredIndex)]"-                    )-                }-            } else if let phrase = definition["phrase"] {-                let arm = try object(phrase, path: "\(path).definition.phrase")-                try keys(arm, allowed: ["prefix", "separator", "suffix", "order"], required: ["prefix", "separator", "suffix", "order"], path: "\(path).definition.phrase")-            }-        }-    }--    private static func validateRange(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(-            record,-            allowed: ["origin", "offset", "length"],-            required: ["origin", "offset", "length"],-            path: path-        )-    }--    private static func validatePosition(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(-            record,-            allowed: ["origin", "offset"],-            required: ["origin", "offset"],-            path: path-        )-    }--    private static func validateProvenance(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(record, allowed: ["kind", "patternID", "patternVersion"], required: ["kind", "patternID", "patternVersion"], path: path)-    }--    private static func keys(-        _ object: [String: Any],-        allowed: Set<String>,-        required: Set<String>,-        path: String-    ) throws {-        if let unknown = Set(object.keys).subtracting(allowed).sorted().first {-            throw BackupCodecError.unknownKey("\(path).\(unknown)")-        }-        if let missing = required.subtracting(object.keys).sorted().first {-            throw BackupCodecError.missingKey("\(path).\(missing)")-        }-    }--    private static func object(_ value: Any?, path: String) throws -> [String: Any] {-        guard let value = value as? [String: Any] else {-            throw BackupCodecError.invalidValue(key: path, reason: "expected object")-        }-        return value-    }--    private static func array(_ value: Any?, path: String) throws -> [Any] {-        guard let value = value as? [Any] else {-            throw BackupCodecError.invalidValue(key: path, reason: "expected array")-        }-        return value-    }-}
Packages/AsterismCore/Sources/AsterismCore/BackupV3Codec.swift Deleted +0 / -376
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV3Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV3Codec.swiftdeleted file mode 100644index 08d4003..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV3Codec.swift+++ /dev/null@@ -1,376 +0,0 @@-import Foundation-import CryptoKit--/// Strict Backup V3 codec. Encodes from a coherent snapshot, validates on decode.-///-/// Export produces canonical JSON, computes a SHA-256 checksum of the payload bytes,-/// and includes entry/work counts for integrity. Decode validates the envelope,-/// rejects unknown/duplicate keys, verifies checksum, and validates all references.-public enum BackupV3Codec {-    /// The historical capability gate for the frozen 3/3 format. Pinned to the-    /// literal `"m3"` rather than `AsterismCapabilities.current` (Decision 2):-    /// once `current` flips to `.m4` for the 4/4 work, a 3/3 payload must still-    /// declare `"m3"` — a frozen format is never redefined in place.-    static let gate = "m3"--    // MARK: - Encode--    /// Encodes a complete V3 backup from the coherent snapshot.-    ///-    /// The exporter is responsible for calling this and then decode-validating-    /// the produced bytes before sharing.-    public static func encode(-        payload: BackupV3Payload,-        metadata: BackupV3Metadata-    ) throws -> Data {-        let encoder = JSONEncoder()-        encoder.dateEncodingStrategy = .custom(encodeV3Date)-        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]--        let payloadData = try encoder.encode(payload)-        let checksum = sha256Hex(payloadData)--        let document = BackupV3Document(-            appBuild: metadata.appBuild,-            exportedAt: metadata.exportedAt,-            capabilityGate: Self.gate,-            entryCount: payload.entries.count,-            workCount: payload.works.count,-            checksum: checksum,-            payload: payload-        )--        return try encoder.encode(document)-    }--    // MARK: - Decode--    /// Decodes and validates a Backup V3 document from JSON data.-    ///-    /// Validates: envelope format/schema, capability gate, duplicate keys,-    /// strict shape, entry/work counts, payload checksum, and all references.-    public static func decode(_ data: Data) throws -> BackupV3Document {-        do {-            try DuplicateJSONKeyValidator.validate(data)-            try BackupV3ShapeValidator.validate(data)--            let decoder = JSONDecoder()-            decoder.dateDecodingStrategy = .custom(decodeV3Date)-            let document = try decoder.decode(BackupV3Document.self, from: data)--            guard document.backupFormatVersion == BackupV3Document.formatVersion else {-                throw BackupV3CodecError.invalidFormatVersion(document.backupFormatVersion)-            }-            guard document.databaseSchemaVersion == BackupV3Document.schemaVersion else {-                throw BackupV3CodecError.invalidSchemaVersion(document.databaseSchemaVersion)-            }-            guard document.capabilityGate == Self.gate else {-                throw BackupV3CodecError.unsupportedGate(document.capabilityGate)-            }--            // Verify counts-            guard document.entryCount == document.payload.entries.count else {-                throw BackupV3CodecError.countMismatch(-                    field: "entryCount",-                    expected: document.entryCount,-                    actual: document.payload.entries.count-                )-            }-            guard document.workCount == document.payload.works.count else {-                throw BackupV3CodecError.countMismatch(-                    field: "workCount",-                    expected: document.workCount,-                    actual: document.payload.works.count-                )-            }--            // Verify checksum: re-encode payload with same settings-            let payloadEncoder = JSONEncoder()-            payloadEncoder.dateEncodingStrategy = .custom(encodeV3Date)-            payloadEncoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]-            let payloadData = try payloadEncoder.encode(document.payload)-            let computedChecksum = sha256Hex(payloadData)-            guard document.checksum == computedChecksum else {-                throw BackupV3CodecError.checksumMismatch(-                    expected: document.checksum,-                    actual: computedChecksum-                )-            }--            // Validate references and closed tuples-            try BackupV3ReferenceValidator.validate(payload: document.payload)--            return document-        } catch let error as BackupV3CodecError { throw error }-        catch let error as BackupCodecError { throw error }-        catch {-            throw BackupV3CodecError.decodingFailed(reason: String(describing: error))-        }-    }--    // MARK: - Utilities--    private static func sha256Hex(_ data: Data) -> String {-        let digest = SHA256.hash(data: data)-        return digest.map { String(format: "%02x", $0) }.joined()-    }--    private static func encodeV3Date(_ date: Date, encoder: Encoder) throws {-        var container = encoder.singleValueContainer()-        try container.encode(LegacyV2DateFormatter.string(from: date))-    }--    private static func decodeV3Date(_ decoder: Decoder) throws -> Date {-        let container = try decoder.singleValueContainer()-        let value = try container.decode(String.self)-        guard let date = LegacyV2DateFormatter.date(from: value) else {-            throw DecodingError.dataCorruptedError(-                in: container,-                debugDescription: "date must be RFC 3339 UTC with milliseconds"-            )-        }-        return date-    }-}--// MARK: - V3 Codec Error--public enum BackupV3CodecError: Error, Equatable, Sendable, CustomStringConvertible {-    case encodingFailed(reason: String)-    case decodingFailed(reason: String)-    case invalidFormatVersion(Int)-    case invalidSchemaVersion(Int)-    case unsupportedGate(String)-    case countMismatch(field: String, expected: Int, actual: Int)-    case checksumMismatch(expected: String, actual: String)-    case unresolvedReference(type: String, id: String, reference: String)-    case invalidStateTuple(type: String, id: String, reason: String)--    public var description: String {-        switch self {-        case .encodingFailed(let reason): "Backup V3 encoding failed: \(reason)"-        case .decodingFailed(let reason): "Backup V3 decoding failed: \(reason)"-        case .invalidFormatVersion(let v): "Backup V3 unsupported format version: \(v)"-        case .invalidSchemaVersion(let v): "Backup V3 unsupported schema version: \(v)"-        case .unsupportedGate(let g): "Backup V3 unsupported capability gate: \(g)"-        case .countMismatch(let field, let expected, let actual):-            "Backup V3 \(field) mismatch: header says \(expected), payload has \(actual)"-        case .checksumMismatch(let expected, let actual):-            "Backup V3 checksum mismatch: expected \(expected), computed \(actual)"-        case .unresolvedReference(let type, let id, let reference):-            "Backup V3 \(type) \(id) has unresolved reference: \(reference)"-        case .invalidStateTuple(let type, let id, let reason):-            "Backup V3 invalid \(type) tuple \(id): \(reason)"-        }-    }-}--// MARK: - V3 Metadata--public struct BackupV3Metadata: Sendable {-    public let appBuild: String-    public let exportedAt: Date--    public init(appBuild: String, exportedAt: Date) {-        self.appBuild = appBuild-        self.exportedAt = exportedAt-    }-}--// MARK: - V3 Shape Validator--/// Validates the V3 JSON has the expected shape including counts and checksum fields.-internal enum BackupV3ShapeValidator {-    static func validate(_ data: Data) throws {-        let object = try JSONSerialization.jsonObject(with: data)-        guard let root = object as? [String: Any] else {-            throw BackupCodecError.invalidValue(key: "$", reason: "expected object")-        }-        let required: Set<String> = [-            "backupFormatVersion", "databaseSchemaVersion", "appBuild",-            "exportedAt", "capabilityGate", "entryCount", "workCount",-            "checksum", "payload",-        ]-        let allowed = required-        if let unknown = Set(root.keys).subtracting(allowed).sorted().first {-            throw BackupCodecError.unknownKey("$.\(unknown)")-        }-        if let missing = required.subtracting(root.keys).sorted().first {-            throw BackupCodecError.missingKey("$.\(missing)")-        }-        // Payload shape validated via typed decoding; additional deep validation-        // happens in BackupV3ReferenceValidator after decoding.-    }-}--// MARK: - V3 Reference Validator--/// Validates internal references and closed tuples within a V3 backup payload.-internal enum BackupV3ReferenceValidator {-    static func validate(payload: BackupV3Payload) throws {-        let siteHostnames = Set(payload.sites.map(\.hostname))-        let entryIDs = Set(payload.entries.map(\.id))-        let workIDs = Set(payload.works.map(\.id))-        let patternIDs = Set(payload.titlePatterns.map(\.id))-        let ruleIDs = Set(payload.urlRules.map(\.id))--        // Duplicate detection-        guard entryIDs.count == payload.entries.count else {-            throw BackupV3CodecError.invalidStateTuple(type: "Payload", id: "V3", reason: "duplicate Entry ID")-        }-        guard workIDs.count == payload.works.count else {-            throw BackupV3CodecError.invalidStateTuple(type: "Payload", id: "V3", reason: "duplicate Work ID")-        }-        guard siteHostnames.count == payload.sites.count else {-            throw BackupV3CodecError.invalidStateTuple(type: "Payload", id: "V3", reason: "duplicate Site hostname")-        }-        guard patternIDs.count == payload.titlePatterns.count else {-            throw BackupV3CodecError.invalidStateTuple(type: "Payload", id: "V3", reason: "duplicate TitlePattern ID")-        }-        guard ruleIDs.count == payload.urlRules.count else {-            throw BackupV3CodecError.invalidStateTuple(type: "Payload", id: "V3", reason: "duplicate URLRule ID")-        }--        // Build rule lookup for reference resolution-        let rulesByID: [UUID: BackupV3URLRule] = Dictionary(-            uniqueKeysWithValues: payload.urlRules.map { ($0.id, $0) }-        )-        let patternsByID: [UUID: BackupV3TitlePattern] = Dictionary(-            uniqueKeysWithValues: payload.titlePatterns.map { ($0.id, $0) }-        )--        // Validate Sites-        for site in payload.sites {-            for patternID in site.patternIDs {-                guard let pattern = patternsByID[patternID], pattern.siteHostname == site.hostname else {-                    throw BackupV3CodecError.unresolvedReference(-                        type: "Site", id: site.hostname, reference: "TitlePattern \(patternID)"-                    )-                }-            }-            for ruleID in site.urlRuleIDs {-                guard let rule = rulesByID[ruleID], rule.siteHostname == site.hostname else {-                    throw BackupV3CodecError.unresolvedReference(-                        type: "Site", id: site.hostname, reference: "URLRule \(ruleID)"-                    )-                }-            }-        }--        // Validate Works-        for work in payload.works {-            guard siteHostnames.contains(work.siteHostname) else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Work", id: work.id.uuidString, reference: "Site \(work.siteHostname)"-                )-            }-            for entryID in work.entryIDs {-                guard entryIDs.contains(entryID) else {-                    throw BackupV3CodecError.unresolvedReference(-                        type: "Work", id: work.id.uuidString, reference: "Entry \(entryID)"-                    )-                }-            }-            if let ruleID = work.urlIdentityRuleID {-                guard let rule = rulesByID[ruleID],-                      rule.siteHostname == work.siteHostname,-                      let ruleVersion = work.urlIdentityRuleVersion,-                      rule.version == ruleVersion else {-                    throw BackupV3CodecError.unresolvedReference(-                        type: "Work", id: work.id.uuidString, reference: "URL rule identity"-                    )-                }-            }-        }--        // Validate Entries-        for entry in payload.entries {-            guard siteHostnames.contains(entry.hostname) else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Entry", id: entry.id.uuidString, reference: "Site \(entry.hostname)"-                )-            }-            if let workID = entry.workID {-                guard workIDs.contains(workID) else {-                    throw BackupV3CodecError.unresolvedReference(-                        type: "Entry", id: entry.id.uuidString, reference: "Work \(workID)"-                    )-                }-            }-            try validateEntryRuleReferences(entry, rulesByID: rulesByID)-        }--        // Validate TitlePatterns-        for pattern in payload.titlePatterns {-            guard siteHostnames.contains(pattern.siteHostname) else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "TitlePattern", id: pattern.id.uuidString, reference: "Site \(pattern.siteHostname)"-                )-            }-        }--        // Validate URLRules-        for rule in payload.urlRules {-            guard siteHostnames.contains(rule.siteHostname) else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "URLRule", id: rule.id.uuidString, reference: "Site \(rule.siteHostname)"-                )-            }-        }-    }--    private static func validateEntryRuleReferences(-        _ entry: BackupV3Entry,-        rulesByID: [UUID: BackupV3URLRule]-    ) throws {-        let id = entry.id.uuidString--        // Identity URL rule reference-        if let ruleID = entry.identityURLRuleID {-            guard let rule = rulesByID[ruleID],-                  let version = entry.identityURLRuleVersion,-                  rule.version == version,-                  rule.siteHostname == entry.hostname else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Entry", id: id, reference: "identity URL rule"-                )-            }-        }--        // Work extraction URL rule reference-        if let ruleID = entry.urlWorkRuleID {-            guard let rule = rulesByID[ruleID],-                  let version = entry.urlWorkRuleVersion,-                  rule.version == version,-                  rule.siteHostname == entry.hostname else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Entry", id: id, reference: "Work extraction URL rule"-                )-            }-        }--        // Chapter sequence URL rule reference-        if let ruleID = entry.chapterSequenceRuleID {-            guard let rule = rulesByID[ruleID],-                  let version = entry.chapterSequenceRuleVersion,-                  rule.version == version,-                  rule.siteHostname == entry.hostname else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Entry", id: id, reference: "chapter sequence URL rule"-                )-            }-        }--        // Assignment URL rule reference-        if let ruleID = entry.workURLRuleID {-            guard let rule = rulesByID[ruleID],-                  let version = entry.workURLRuleVersion,-                  rule.version == version,-                  rule.siteHostname == entry.hostname else {-                throw BackupV3CodecError.unresolvedReference(-                    type: "Entry", id: id, reference: "assignment URL rule"-                )-            }-        }-    }-}
Packages/AsterismCore/Sources/AsterismCore/BackupV3Types.swift Deleted +0 / -333
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV3Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV3Types.swiftdeleted file mode 100644index 69a33a7..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV3Types.swift+++ /dev/null@@ -1,333 +0,0 @@-import Foundation--// MARK: - Frozen V3 wire enums/value types--/// Frozen copies of the M3 Site title-interpretation and Work-title trim that the-/// `3/3` backup format carries. The live `SiteTitleInterpretation` /-/// `WorkTitleTrimRule` are removed in schema V4 (their Site columns are dropped),-/// but the `3/3` wire format is frozen forever (Decision 2), so it keeps its own-/// self-contained copies with identical `String` raw values / Codable shape. This-/// keeps every `3/3` payload byte-identical after the live types are deleted.-public enum BackupV3Types {-    public enum FrozenTitleInterpretation: String, CaseIterable, Codable, Sendable {-        case pattern-        case wholeCaptureTitle-    }--    public struct FrozenWorkTitleTrimRule: Codable, Equatable, Sendable {-        public let prefix: String-        public let suffix: String--        public init(prefix: String, suffix: String) {-            self.prefix = prefix-            self.suffix = suffix-        }-    }-}--// MARK: - Backup V3 Document--/// The V3 backup envelope. Format version 3, schema version 3.-/// Includes entry/work counts and a checksum over the canonical payload bytes.-public struct BackupV3Document: Codable, Equatable, Sendable {-    public static let formatVersion = 3-    public static let schemaVersion = 3--    public let backupFormatVersion: Int-    public let databaseSchemaVersion: Int-    public let appBuild: String-    public let exportedAt: Date-    public let capabilityGate: String-    public let entryCount: Int-    public let workCount: Int-    public let checksum: String-    public let payload: BackupV3Payload--    public init(-        appBuild: String,-        exportedAt: Date,-        capabilityGate: String,-        entryCount: Int,-        workCount: Int,-        checksum: String,-        payload: BackupV3Payload-    ) {-        backupFormatVersion = Self.formatVersion-        databaseSchemaVersion = Self.schemaVersion-        self.appBuild = appBuild-        self.exportedAt = exportedAt-        self.capabilityGate = capabilityGate-        self.entryCount = entryCount-        self.workCount = workCount-        self.checksum = checksum-        self.payload = payload-    }-}--// MARK: - V3 Payload--public struct BackupV3Payload: Codable, Equatable, Sendable {-    public let entries: [BackupV3Entry]-    public let works: [BackupV3Work]-    public let sites: [BackupV3Site]-    public let titlePatterns: [BackupV3TitlePattern]-    public let urlRules: [BackupV3URLRule]--    public init(-        entries: [BackupV3Entry],-        works: [BackupV3Work],-        sites: [BackupV3Site],-        titlePatterns: [BackupV3TitlePattern],-        urlRules: [BackupV3URLRule]-    ) {-        self.entries = entries-        self.works = works-        self.sites = sites-        self.titlePatterns = titlePatterns-        self.urlRules = urlRules-    }-}--// MARK: - V3 Records--public struct BackupV3Entry: Codable, Equatable, Sendable {-    public let id: UUID-    public let captureTitle: String-    public let captureTitleSource: CaptureTitleSource-    public let rawURL: String-    public let canonicalURL: String?-    public let hostname: String-    public let entryIdentityKey: String-    public let identityKeyVersion: Int-    public let identityBasis: EntryIdentityBasis-    public let identityURLRuleID: UUID?-    public let identityURLRuleVersion: Int?-    public let urlWorkIdentity: String?-    public let urlWorkRuleID: UUID?-    public let urlWorkRuleVersion: Int?-    public let chapterSequence: String?-    public let chapterSequenceRuleID: UUID?-    public let chapterSequenceRuleVersion: Int?-    public let chapterTitle: String?-    public let chapterTitleProvenance: FieldProvenance-    public let note: String-    public let rating: Rating?-    public let firstCapturedAt: Date-    public let lastSharedAt: Date-    public let modifiedAt: Date-    public let workID: UUID?-    public let workAssignmentProvenance: FieldProvenance-    public let workURLRuleID: UUID?-    public let workURLRuleVersion: Int?-    public let workURLAssignmentKind: URLWorkAssignmentKind?-    public let workPatternID: UUID?-    public let workPatternVersion: Int?-    public let intentionallyUnattached: Bool--    public init(-        id: UUID,-        captureTitle: String,-        captureTitleSource: CaptureTitleSource,-        rawURL: String,-        canonicalURL: String?,-        hostname: String,-        entryIdentityKey: String,-        identityKeyVersion: Int,-        identityBasis: EntryIdentityBasis,-        identityURLRuleID: UUID?,-        identityURLRuleVersion: Int?,-        urlWorkIdentity: String?,-        urlWorkRuleID: UUID?,-        urlWorkRuleVersion: Int?,-        chapterSequence: String?,-        chapterSequenceRuleID: UUID?,-        chapterSequenceRuleVersion: Int?,-        chapterTitle: String?,-        chapterTitleProvenance: FieldProvenance,-        note: String,-        rating: Rating?,-        firstCapturedAt: Date,-        lastSharedAt: Date,-        modifiedAt: Date,-        workID: UUID?,-        workAssignmentProvenance: FieldProvenance,-        workURLRuleID: UUID?,-        workURLRuleVersion: Int?,-        workURLAssignmentKind: URLWorkAssignmentKind?,-        workPatternID: UUID?,-        workPatternVersion: Int?,-        intentionallyUnattached: Bool-    ) {-        self.id = id-        self.captureTitle = captureTitle-        self.captureTitleSource = captureTitleSource-        self.rawURL = rawURL-        self.canonicalURL = canonicalURL-        self.hostname = hostname-        self.entryIdentityKey = entryIdentityKey-        self.identityKeyVersion = identityKeyVersion-        self.identityBasis = identityBasis-        self.identityURLRuleID = identityURLRuleID-        self.identityURLRuleVersion = identityURLRuleVersion-        self.urlWorkIdentity = urlWorkIdentity-        self.urlWorkRuleID = urlWorkRuleID-        self.urlWorkRuleVersion = urlWorkRuleVersion-        self.chapterSequence = chapterSequence-        self.chapterSequenceRuleID = chapterSequenceRuleID-        self.chapterSequenceRuleVersion = chapterSequenceRuleVersion-        self.chapterTitle = chapterTitle-        self.chapterTitleProvenance = chapterTitleProvenance-        self.note = note-        self.rating = rating-        self.firstCapturedAt = firstCapturedAt-        self.lastSharedAt = lastSharedAt-        self.modifiedAt = modifiedAt-        self.workID = workID-        self.workAssignmentProvenance = workAssignmentProvenance-        self.workURLRuleID = workURLRuleID-        self.workURLRuleVersion = workURLRuleVersion-        self.workURLAssignmentKind = workURLAssignmentKind-        self.workPatternID = workPatternID-        self.workPatternVersion = workPatternVersion-        self.intentionallyUnattached = intentionallyUnattached-    }-}--public struct BackupV3Work: Codable, Equatable, Sendable {-    public let id: UUID-    public let displayTitle: String-    public let lastParsedTitle: String?-    public let siteHostname: String-    public let urlIdentity: String?-    public let urlIdentityState: WorkURLIdentityState-    public let urlIdentityRuleID: UUID?-    public let urlIdentityRuleVersion: Int?-    public let workURL: String?-    public let genericNotes: String-    public let type: WorkType-    public let genreTags: [String]-    public let titleProvenance: TitleProvenance-    public let createdAt: Date-    public let modifiedAt: Date-    public let entryIDs: [UUID]--    public init(-        id: UUID,-        displayTitle: String,-        lastParsedTitle: String?,-        siteHostname: String,-        urlIdentity: String?,-        urlIdentityState: WorkURLIdentityState,-        urlIdentityRuleID: UUID?,-        urlIdentityRuleVersion: Int?,-        workURL: String?,-        genericNotes: String,-        type: WorkType,-        genreTags: [String],-        titleProvenance: TitleProvenance,-        createdAt: Date,-        modifiedAt: Date,-        entryIDs: [UUID]-    ) {-        self.id = id-        self.displayTitle = displayTitle-        self.lastParsedTitle = lastParsedTitle-        self.siteHostname = siteHostname-        self.urlIdentity = urlIdentity-        self.urlIdentityState = urlIdentityState-        self.urlIdentityRuleID = urlIdentityRuleID-        self.urlIdentityRuleVersion = urlIdentityRuleVersion-        self.workURL = workURL-        self.genericNotes = genericNotes-        self.type = type-        self.genreTags = genreTags-        self.titleProvenance = titleProvenance-        self.createdAt = createdAt-        self.modifiedAt = modifiedAt-        self.entryIDs = entryIDs-    }-}--public struct BackupV3Site: Codable, Equatable, Sendable {-    public let hostname: String-    public let displayName: String-    public let mode: SiteMode-    public let titleInterpretation: BackupV3Types.FrozenTitleInterpretation?-    public let patternIDs: [UUID]-    public let urlRuleIDs: [UUID]-    public let junkSuffixRule: JunkSuffixRule?-    public let workTitleTrimRule: BackupV3Types.FrozenWorkTitleTrimRule?--    public init(-        hostname: String,-        displayName: String,-        mode: SiteMode,-        titleInterpretation: BackupV3Types.FrozenTitleInterpretation?,-        patternIDs: [UUID],-        urlRuleIDs: [UUID],-        junkSuffixRule: JunkSuffixRule?,-        workTitleTrimRule: BackupV3Types.FrozenWorkTitleTrimRule? = nil-    ) {-        self.hostname = hostname-        self.displayName = displayName-        self.mode = mode-        self.titleInterpretation = titleInterpretation-        self.patternIDs = patternIDs-        self.urlRuleIDs = urlRuleIDs-        self.junkSuffixRule = junkSuffixRule-        self.workTitleTrimRule = workTitleTrimRule-    }-}--public struct BackupV3TitlePattern: Codable, Equatable, Sendable {-    public let id: UUID-    public let version: Int-    public let isActive: Bool-    public let createdAt: Date-    public let definition: PatternDefinition-    public let siteHostname: String--    public init(-        id: UUID,-        version: Int,-        isActive: Bool,-        createdAt: Date,-        definition: PatternDefinition,-        siteHostname: String-    ) {-        self.id = id-        self.version = version-        self.isActive = isActive-        self.createdAt = createdAt-        self.definition = definition-        self.siteHostname = siteHostname-    }-}--public struct BackupV3URLRule: Codable, Equatable, Sendable {-    public let id: UUID-    public let version: Int-    public let isCurrent: Bool-    public let createdAt: Date-    public let origin: URLRuleOrigin-    public let definition: URLRuleDefinition-    public let siteHostname: String--    public init(-        id: UUID,-        version: Int,-        isCurrent: Bool,-        createdAt: Date,-        origin: URLRuleOrigin,-        definition: URLRuleDefinition,-        siteHostname: String-    ) {-        self.id = id-        self.version = version-        self.isCurrent = isCurrent-        self.createdAt = createdAt-        self.origin = origin-        self.definition = definition-        self.siteHostname = siteHostname-    }-}
Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Codec.swift Deleted +0 / -584
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Codec.swiftdeleted file mode 100644index 22ea731..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Codec.swift+++ /dev/null@@ -1,584 +0,0 @@-import Foundation--/// Frozen decode-only codec for the legacy Backup V2 wire format.-///-/// This codec accepts only the exact six-key `2/2/m2.3` envelope. It never-/// encodes — encoding exists only in the test-target-only fixture exporter.-/// The shape validator, duplicate-key validator, and date decoding are-/// mechanically identical to the pre-M3 implementation.-public enum LegacyBackupV2Codec {-    /// Decodes a legacy Backup V2 document from JSON data.-    ///-    /// - Rejects duplicate keys, unknown/missing root/payload/record keys.-    /// - Accepts only format 2, schema 2, capability gate `m2.3`.-    /// - Validates patterns/articles/assignment tuples via `V2LibraryValidator`.-    /// - Validates dormant URL fields via `LegacyV2URLFieldValidator`.-    public static func decode(_ data: Data) throws -> LegacyBackupV2Document {-        do {-            // Reject duplicate JSON keys before any typed decoding-            try DuplicateJSONKeyValidator.validate(data)--            // Strict shape validation: exact keys at every level-            let object = try JSONSerialization.jsonObject(with: data)-            try LegacyV2ShapeValidator.validate(object)--            // Typed decoding-            let decoder = JSONDecoder()-            decoder.dateDecodingStrategy = .custom(decodeLegacyDate)-            let document = try decoder.decode(LegacyBackupV2Document.self, from: data)--            // Envelope validation-            guard document.backupFormatVersion == LegacyBackupV2Document.formatVersion else {-                throw LegacyBackupV2CodecError.invalidFormatVersion(document.backupFormatVersion)-            }-            guard document.databaseSchemaVersion == LegacyBackupV2Document.schemaVersion else {-                throw LegacyBackupV2CodecError.invalidSchemaVersion(document.databaseSchemaVersion)-            }-            guard document.capabilityGate == .m2_3 else {-                throw LegacyBackupV2CodecError.unsupportedGate(document.capabilityGate)-            }--            // Legacy structural validation (pattern/articles/assignment tuples)-            let legacySnapshot = toLegacySnapshot(document.payload)-            try V2LibraryValidator.validate(snapshot: legacySnapshot, capabilities: .m2_3)--            // Dormant URL field validation (Requirement 1.16)-            try LegacyV2URLFieldValidator.validate(payload: document.payload)--            return document-        } catch let error as LegacyBackupV2CodecError { throw error }-        catch let error as BackupValidationError { throw error }-        catch let error as BackupCodecError { throw error }-        catch {-            throw LegacyBackupV2CodecError.decodingFailed(-                reason: String(describing: error)-            )-        }-    }--    /// Converts a `LegacyBackupV2Payload` to the `LibraryBackupSnapshot` expected-    /// by `V2LibraryValidator`. This is a structural mapping between isomorphic types.-    private static func toLegacySnapshot(_ payload: LegacyBackupV2Payload) -> LibraryBackupSnapshot {-        LibraryBackupSnapshot(-            entries: payload.entries.map { entry in-                EntryRecord(-                    id: entry.id,-                    captureTitle: entry.captureTitle,-                    captureTitleSource: entry.captureTitleSource,-                    rawURL: entry.rawURL,-                    canonicalURL: entry.canonicalURL,-                    hostname: entry.hostname,-                    entryIdentityKey: entry.entryIdentityKey,-                    identityKeyVersion: entry.identityKeyVersion,-                    chapterTitle: entry.chapterTitle,-                    chapterTitleProvenance: entry.chapterTitleProvenance,-                    note: entry.note,-                    rating: entry.rating,-                    firstCapturedAt: entry.firstCapturedAt,-                    lastSharedAt: entry.lastSharedAt,-                    modifiedAt: entry.modifiedAt,-                    workID: entry.workID,-                    workAssignmentProvenance: entry.workAssignmentProvenance,-                    intentionallyUnattached: entry.intentionallyUnattached-                )-            },-            works: payload.works.map { work in-                WorkRecord(-                    id: work.id,-                    displayTitle: work.displayTitle,-                    lastParsedTitle: work.lastParsedTitle,-                    siteHostname: work.siteHostname,-                    urlIdentity: work.urlIdentity,-                    workURL: work.workURL,-                    genericNotes: work.genericNotes,-                    type: work.type,-                    genreTags: work.genreTags,-                    titleProvenance: work.titleProvenance,-                    createdAt: work.createdAt,-                    modifiedAt: work.modifiedAt,-                    entryIDs: work.entryIDs-                )-            },-            sites: payload.sites.map { site in-                SiteRecord(-                    hostname: site.hostname,-                    displayName: site.displayName,-                    mode: site.mode,-                    patternIDs: site.patternIDs,-                    urlIdentityRule: site.urlIdentityRule,-                    junkSuffixRule: site.junkSuffixRule-                )-            },-            titlePatterns: payload.titlePatterns.map { pattern in-                TitlePatternRecord(-                    id: pattern.id,-                    version: pattern.version,-                    isActive: pattern.isActive,-                    createdAt: pattern.createdAt,-                    definition: pattern.definition,-                    siteHostname: pattern.siteHostname-                )-            }-        )-    }--    private static func decodeLegacyDate(_ decoder: Decoder) throws -> Date {-        let container = try decoder.singleValueContainer()-        let value = try container.decode(String.self)-        guard let date = LegacyV2DateFormatter.date(from: value) else {-            throw DecodingError.dataCorruptedError(-                in: container,-                debugDescription: "date must be RFC 3339 UTC with milliseconds"-            )-        }-        return date-    }-}--// MARK: - Legacy V2 Codec Error--public enum LegacyBackupV2CodecError: Error, Equatable, Sendable, CustomStringConvertible {-    case decodingFailed(reason: String)-    case invalidFormatVersion(Int)-    case invalidSchemaVersion(Int)-    case unsupportedGate(LegacyBackupV2Gate)-    case invalidDormantURLField(hostname: String, reason: String)--    public var description: String {-        switch self {-        case .decodingFailed(let reason):-            "Legacy Backup V2 decoding failed: \(reason)"-        case .invalidFormatVersion(let value):-            "Legacy Backup V2 unsupported format version: \(value)"-        case .invalidSchemaVersion(let value):-            "Legacy Backup V2 unsupported schema version: \(value)"-        case .unsupportedGate(let gate):-            "Legacy Backup V2 unsupported capability gate: \(gate.rawValue)"-        case .invalidDormantURLField(let hostname, let reason):-            "Legacy Backup V2 invalid dormant URL field on Site \(hostname): \(reason)"-        }-    }-}--// MARK: - Legacy Date Formatter--internal enum LegacyV2DateFormatter {-    static func string(from date: Date) -> String {-        formatter().string(from: MillisecondInstant.quantize(date))-    }--    static func date(from value: String) -> Date? {-        formatter().date(from: value)-    }--    private static func formatter() -> ISO8601DateFormatter {-        let formatter = ISO8601DateFormatter()-        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]-        formatter.timeZone = TimeZone(secondsFromGMT: 0)-        return formatter-    }-}--// MARK: - Legacy V2 Shape Validator--/// Validates the exact six-key shape of a frozen Backup V2 JSON document.-/// Identical to the pre-M3 `BackupV2ShapeValidator` — this is a mechanical freeze.-internal enum LegacyV2ShapeValidator {-    static func validate(_ value: Any) throws {-        let root = try object(value, path: "$")-        try keys(-            root,-            allowed: [-                "backupFormatVersion", "databaseSchemaVersion", "appBuild",-                "exportedAt", "capabilityGate", "payload",-            ],-            required: [-                "backupFormatVersion", "databaseSchemaVersion", "appBuild",-                "exportedAt", "capabilityGate", "payload",-            ],-            path: "$"-        )-        let payload = try object(root["payload"], path: "$.payload")-        try keys(-            payload,-            allowed: ["entries", "works", "sites", "titlePatterns"],-            required: ["entries", "works", "sites", "titlePatterns"],-            path: "$.payload"-        )--        try array(payload["entries"], path: "$.payload.entries").enumerated().forEach { index, value in-            let path = "$.payload.entries[\(index)]"-            let record = try object(value, path: path)-            try keys(-                record,-                allowed: [-                    "id", "captureTitle", "captureTitleSource", "rawURL",-                    "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion",-                    "chapterTitle", "chapterTitleProvenance", "note", "rating",-                    "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID",-                    "workAssignmentProvenance", "intentionallyUnattached",-                ],-                required: [-                    "id", "captureTitle", "captureTitleSource", "rawURL",-                    "canonicalURL", "hostname", "entryIdentityKey", "identityKeyVersion",-                    "chapterTitle", "chapterTitleProvenance", "note", "rating",-                    "firstCapturedAt", "lastSharedAt", "modifiedAt", "workID",-                    "workAssignmentProvenance", "intentionallyUnattached",-                ],-                path: path-            )-            try validateProvenance(record["chapterTitleProvenance"], path: "\(path).chapterTitleProvenance")-            try validateProvenance(record["workAssignmentProvenance"], path: "\(path).workAssignmentProvenance")-        }--        try array(payload["works"], path: "$.payload.works").enumerated().forEach { index, value in-            let path = "$.payload.works[\(index)]"-            let record = try object(value, path: path)-            try keys(-                record,-                allowed: [-                    "id", "displayTitle", "lastParsedTitle", "siteHostname",-                    "urlIdentity", "workURL", "genericNotes", "type",-                    "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs",-                ],-                required: [-                    "id", "displayTitle", "lastParsedTitle", "siteHostname",-                    "urlIdentity", "workURL", "genericNotes", "type",-                    "genreTags", "titleProvenance", "createdAt", "modifiedAt", "entryIDs",-                ],-                path: path-            )-        }--        try array(payload["sites"], path: "$.payload.sites").enumerated().forEach { index, value in-            let path = "$.payload.sites[\(index)]"-            let record = try object(value, path: path)-            try keys(-                record,-                allowed: [-                    "hostname", "displayName", "mode", "patternIDs",-                    "urlIdentityRule", "junkSuffixRule",-                ],-                required: [-                    "hostname", "displayName", "mode", "patternIDs",-                    "urlIdentityRule", "junkSuffixRule",-                ],-                path: path-            )-            if let urlIdentityRule = record["urlIdentityRule"], !(urlIdentityRule is NSNull) {-                let rulePath = "\(path).urlIdentityRule"-                let rule = try object(urlIdentityRule, path: rulePath)-                try keys(-                    rule,-                    allowed: ["version", "component", "origin", "offset", "queryName"],-                    required: ["version", "component", "origin", "offset", "queryName"],-                    path: rulePath-                )-            }-            if let junkSuffixRule = record["junkSuffixRule"], !(junkSuffixRule is NSNull) {-                let rulePath = "\(path).junkSuffixRule"-                let rule = try object(junkSuffixRule, path: rulePath)-                try keys(-                    rule,-                    allowed: ["version", "anchors"],-                    required: ["version", "anchors"],-                    path: rulePath-                )-                try array(rule["anchors"], path: "\(rulePath).anchors").enumerated().forEach { ai, anchor in-                    try validatePosition(anchor, path: "\(rulePath).anchors[\(ai)]")-                }-            }-        }--        try array(payload["titlePatterns"], path: "$.payload.titlePatterns").enumerated()-            .forEach { index, value in-                let path = "$.payload.titlePatterns[\(index)]"-                let record = try object(value, path: path)-                try keys(-                    record,-                    allowed: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"],-                    required: ["id", "version", "isActive", "createdAt", "definition", "siteHostname"],-                    path: path-                )-                let definition = try object(record["definition"], path: "\(path).definition")-                try keys(definition, allowed: ["segment", "phrase"], required: [], path: "\(path).definition")-                guard definition.count == 1 else {-                    throw BackupCodecError.invalidValue(-                        key: "\(path).definition",-                        reason: "exactly one tagged form is required"-                    )-                }-                if let segment = definition["segment"] {-                    let arm = try object(segment, path: "\(path).definition.segment")-                    try keys(-                        arm,-                        allowed: ["work", "ignored"],-                        required: ["work", "ignored"],-                        path: "\(path).definition.segment"-                    )-                    try validateRange(arm["work"], path: "\(path).definition.segment.work")-                    try array(arm["ignored"], path: "\(path).definition.segment.ignored")-                        .enumerated().forEach { ii, pos in-                            try validatePosition(pos, path: "\(path).definition.segment.ignored[\(ii)]")-                        }-                } else if let phrase = definition["phrase"] {-                    let arm = try object(phrase, path: "\(path).definition.phrase")-                    try keys(-                        arm,-                        allowed: ["prefix", "separator", "suffix", "order"],-                        required: ["prefix", "separator", "suffix", "order"],-                        path: "\(path).definition.phrase"-                    )-                }-            }-    }--    private static func validateRange(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(record, allowed: ["origin", "offset", "length"], required: ["origin", "offset", "length"], path: path)-    }--    private static func validatePosition(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(record, allowed: ["origin", "offset"], required: ["origin", "offset"], path: path)-    }--    private static func validateProvenance(_ value: Any?, path: String) throws {-        let record = try object(value, path: path)-        try keys(record, allowed: ["kind", "patternID", "patternVersion"], required: ["kind", "patternID", "patternVersion"], path: path)-    }--    private static func keys(-        _ object: [String: Any], allowed: Set<String>, required: Set<String>, path: String-    ) throws {-        if let unknown = Set(object.keys).subtracting(allowed).sorted().first {-            throw BackupCodecError.unknownKey("\(path).\(unknown)")-        }-        if let missing = required.subtracting(object.keys).sorted().first {-            throw BackupCodecError.missingKey("\(path).\(missing)")-        }-    }--    private static func object(_ value: Any?, path: String) throws -> [String: Any] {-        guard let value = value as? [String: Any] else {-            throw BackupCodecError.invalidValue(key: path, reason: "expected object")-        }-        return value-    }--    private static func array(_ value: Any?, path: String) throws -> [Any] {-        guard let value = value as? [Any] else {-            throw BackupCodecError.invalidValue(key: path, reason: "expected array")-        }-        return value-    }-}--// MARK: - Legacy V2 URL Field Validator (Requirement 1.16)--/// Validates dormant Site URL-rule fields in a legacy Backup V2 payload.-///-/// The existing `V2LibraryValidator` intentionally does not validate dormant URL-/// fields. This validator enforces Requirement 1.16: a dormant rule must be absent-/// or have a positive version and exactly one valid nonnegative path edge/offset-/// selector or one nonblank query-name selector. Work `urlIdentity` must be absent-/// or nonblank, and `workURL` must be absent or a valid HTTP(S) URL.-public enum LegacyV2URLFieldValidator {-    public static func validate(payload: LegacyBackupV2Payload) throws {-        for site in payload.sites {-            if let rule = site.urlIdentityRule {-                try validateRule(rule, hostname: site.hostname)-            }-        }-        for work in payload.works {-            if let identity = work.urlIdentity {-                guard !M2Unicode.isBlank(identity) else {-                    throw LegacyBackupV2CodecError.invalidDormantURLField(-                        hostname: work.siteHostname,-                        reason: "Work \(work.id) has blank URL identity"-                    )-                }-            }-            if let url = work.workURL {-                guard isValidWorkURL(url) else {-                    throw LegacyBackupV2CodecError.invalidDormantURLField(-                        hostname: work.siteHostname,-                        reason: "Work \(work.id) has invalid Work URL"-                    )-                }-            }-        }-    }--    private static func validateRule(_ rule: URLIdentityRule, hostname: String) throws {-        guard rule.version > 0 else {-            throw LegacyBackupV2CodecError.invalidDormantURLField(-                hostname: hostname,-                reason: "URL rule version must be positive"-            )-        }-        switch rule.component {-        case .pathSegment:-            guard let origin = rule.origin, let offset = rule.offset, offset >= 0,-                  rule.queryName == nil else {-                throw LegacyBackupV2CodecError.invalidDormantURLField(-                    hostname: hostname,-                    reason: "path rule requires origin, nonnegative offset, and no query name"-                )-            }-            // Validate origin is a valid edge (start or end)-            _ = origin-        case .queryItem:-            guard let name = rule.queryName, !M2Unicode.isBlank(name),-                  rule.origin == nil, rule.offset == nil else {-                throw LegacyBackupV2CodecError.invalidDormantURLField(-                    hostname: hostname,-                    reason: "query rule requires nonblank name and no origin/offset"-                )-            }-        }-    }--    private static func isValidWorkURL(_ value: String) -> Bool {-        guard let components = URLComponents(string: value),-              let scheme = components.scheme?.lowercased(),-              scheme == "http" || scheme == "https",-              let host = components.host, !host.isEmpty else { return false }-        return true-    }-}--// MARK: - Duplicate JSON Key Validator (shared)--/// Validates JSON for duplicate keys. Shared by legacy V2 and current V3 codecs.-/// This is a mechanical freeze from the pre-M3 implementation.-internal struct DuplicateJSONKeyValidator {-    private let bytes: [UInt8]-    private var index = 0--    static func validate(_ data: Data) throws {-        var parser = DuplicateJSONKeyValidator(bytes: Array(data))-        try parser.parseValue(path: "$")-        parser.skipWhitespace()-        guard parser.index == parser.bytes.count else {-            throw BackupCodecError.trailingBytes-        }-    }--    private mutating func parseValue(path: String) throws {-        skipWhitespace()-        guard let byte = current else {-            throw BackupCodecError.decodingFailed(reason: "unexpected end of JSON")-        }-        switch byte {-        case 0x7B: try parseObject(path: path)-        case 0x5B: try parseArray(path: path)-        case 0x22: _ = try parseString()-        case 0x74: try consume("true")-        case 0x66: try consume("false")-        case 0x6E: try consume("null")-        case 0x2D, 0x30...0x39: parseNumber()-        default: throw BackupCodecError.decodingFailed(reason: "unexpected JSON token at byte \(index)")-        }-    }--    private mutating func parseObject(path: String) throws {-        index += 1-        skipWhitespace()-        if consumeIf(0x7D) { return }-        var keys: Set<String> = []-        while true {-            skipWhitespace()-            let key = try parseString()-            guard keys.insert(key).inserted else {-                throw BackupCodecError.duplicateKey("\(path).\(key)")-            }-            skipWhitespace()-            try require(0x3A)-            try parseValue(path: "\(path).\(key)")-            skipWhitespace()-            if consumeIf(0x7D) { return }-            try require(0x2C)-        }-    }--    private mutating func parseArray(path: String) throws {-        index += 1-        skipWhitespace()-        if consumeIf(0x5D) { return }-        var element = 0-        while true {-            try parseValue(path: "\(path)[\(element)]")-            element += 1-            skipWhitespace()-            if consumeIf(0x5D) { return }-            try require(0x2C)-        }-    }--    private mutating func parseString() throws -> String {-        guard current == 0x22 else {-            throw BackupCodecError.decodingFailed(reason: "expected JSON string at byte \(index)")-        }-        let start = index-        index += 1-        var escaped = false-        while let byte = current {-            index += 1-            if escaped {-                escaped = false-            } else if byte == 0x5C {-                escaped = true-            } else if byte == 0x22 {-                let slice = Data(bytes[start..<index])-                do { return try JSONDecoder().decode(String.self, from: slice) }-                catch {-                    throw BackupCodecError.decodingFailed(reason: "invalid JSON string at byte \(start)")-                }-            } else if byte < 0x20 {-                throw BackupCodecError.decodingFailed(reason: "unescaped control scalar in JSON string")-            }-        }-        throw BackupCodecError.decodingFailed(reason: "unterminated JSON string")-    }--    private mutating func parseNumber() {-        while let byte = current,-              byte == 0x2D || byte == 0x2B || byte == 0x2E ||-              byte == 0x45 || byte == 0x65 || (0x30...0x39).contains(byte) {-            index += 1-        }-    }--    private mutating func consume(_ literal: StaticString) throws {-        let expected = Array(String(describing: literal).utf8)-        guard index + expected.count <= bytes.count,-              Array(bytes[index..<(index + expected.count)]) == expected else {-            throw BackupCodecError.decodingFailed(reason: "invalid JSON literal at byte \(index)")-        }-        index += expected.count-    }--    private mutating func require(_ byte: UInt8) throws {-        skipWhitespace()-        guard consumeIf(byte) else {-            throw BackupCodecError.decodingFailed(reason: "missing JSON punctuation at byte \(index)")-        }-    }--    private mutating func consumeIf(_ byte: UInt8) -> Bool {-        guard current == byte else { return false }-        index += 1-        return true-    }--    private mutating func skipWhitespace() {-        while let byte = current, byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D {-            index += 1-        }-    }--    private var current: UInt8? {-        index < bytes.count ? bytes[index] : nil-    }-}
Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Gate.swift Deleted +0 / -19
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Gate.swift b/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Gate.swiftdeleted file mode 100644index da68c87..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Gate.swift+++ /dev/null@@ -1,19 +0,0 @@-import Foundation--/// Import-only capability gate for the frozen Backup V2 wire format.-///-/// This enum is independent of `AsterismCapabilities` and exists solely to-/// validate the `capabilityGate` field in a legacy Backup V2 document during-/// import. M3 accepts only `.m2_3`; earlier gates are recognized but rejected.-///-/// No product target uses this value for current export. The gate freezes the-/// shipped M2.3 representation; it is never renamed or extended with M3 values.-public enum LegacyBackupV2Gate: String, CaseIterable, Codable, Sendable {-    case m2_0 = "m2.0"-    case m2_1 = "m2.1"-    case m2_2 = "m2.2"-    case m2_3 = "m2.3"--    /// The only gate accepted for V2 import into a V3 library.-    public static let accepted: LegacyBackupV2Gate = .m2_3-}
Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Types.swift Deleted +0 / -220
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Types.swift b/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Types.swiftdeleted file mode 100644index 0d6e9d8..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/LegacyBackupV2Types.swift+++ /dev/null@@ -1,220 +0,0 @@-import Foundation--// MARK: - Frozen Legacy V2 Document--/// The exact six-key root envelope of the shipped Backup V2 format.-///-/// This DTO is import-only: it preserves the exact wire shape of the M2.3-/// backup so that legacy files remain decodable without runtime V2 export.-/// No V3 fields (counts, checksum) appear in this envelope.-public struct LegacyBackupV2Document: Codable, Equatable, Sendable {-    public static let formatVersion = 2-    public static let schemaVersion = 2--    public let backupFormatVersion: Int-    public let databaseSchemaVersion: Int-    public let appBuild: String-    public let exportedAt: Date-    public let capabilityGate: LegacyBackupV2Gate-    public let payload: LegacyBackupV2Payload--    public init(-        backupFormatVersion: Int,-        databaseSchemaVersion: Int,-        appBuild: String,-        exportedAt: Date,-        capabilityGate: LegacyBackupV2Gate,-        payload: LegacyBackupV2Payload-    ) {-        self.backupFormatVersion = backupFormatVersion-        self.databaseSchemaVersion = databaseSchemaVersion-        self.appBuild = appBuild-        self.exportedAt = exportedAt-        self.capabilityGate = capabilityGate-        self.payload = payload-    }-}--/// The payload section of a frozen Backup V2 document, containing exactly-/// `entries`, `works`, `sites`, and `titlePatterns`.-public struct LegacyBackupV2Payload: Codable, Equatable, Sendable {-    public let entries: [LegacyV2EntryRecord]-    public let works: [LegacyV2WorkRecord]-    public let sites: [LegacyV2SiteRecord]-    public let titlePatterns: [LegacyV2TitlePatternRecord]--    public init(-        entries: [LegacyV2EntryRecord],-        works: [LegacyV2WorkRecord],-        sites: [LegacyV2SiteRecord],-        titlePatterns: [LegacyV2TitlePatternRecord]-    ) {-        self.entries = entries-        self.works = works-        self.sites = sites-        self.titlePatterns = titlePatterns-    }-}--// MARK: - Legacy V2 Records--/// Frozen Entry record from the M2.3 backup wire format.-public struct LegacyV2EntryRecord: Codable, Equatable, Sendable {-    public let id: UUID-    public let captureTitle: String-    public let captureTitleSource: CaptureTitleSource-    public let rawURL: String-    public let canonicalURL: String?-    public let hostname: String-    public let entryIdentityKey: String-    public let identityKeyVersion: Int-    public let chapterTitle: String?-    public let chapterTitleProvenance: FieldProvenance-    public let note: String-    public let rating: Rating?-    public let firstCapturedAt: Date-    public let lastSharedAt: Date-    public let modifiedAt: Date-    public let workID: UUID?-    public let workAssignmentProvenance: FieldProvenance-    public let intentionallyUnattached: Bool--    public init(-        id: UUID,-        captureTitle: String,-        captureTitleSource: CaptureTitleSource,-        rawURL: String,-        canonicalURL: String?,-        hostname: String,-        entryIdentityKey: String,-        identityKeyVersion: Int,-        chapterTitle: String?,-        chapterTitleProvenance: FieldProvenance,-        note: String,-        rating: Rating?,-        firstCapturedAt: Date,-        lastSharedAt: Date,-        modifiedAt: Date,-        workID: UUID?,-        workAssignmentProvenance: FieldProvenance,-        intentionallyUnattached: Bool-    ) {-        self.id = id-        self.captureTitle = captureTitle-        self.captureTitleSource = captureTitleSource-        self.rawURL = rawURL-        self.canonicalURL = canonicalURL-        self.hostname = hostname-        self.entryIdentityKey = entryIdentityKey-        self.identityKeyVersion = identityKeyVersion-        self.chapterTitle = chapterTitle-        self.chapterTitleProvenance = chapterTitleProvenance-        self.note = note-        self.rating = rating-        self.firstCapturedAt = firstCapturedAt-        self.lastSharedAt = lastSharedAt-        self.modifiedAt = modifiedAt-        self.workID = workID-        self.workAssignmentProvenance = workAssignmentProvenance-        self.intentionallyUnattached = intentionallyUnattached-    }-}--/// Frozen Work record from the M2.3 backup wire format.-public struct LegacyV2WorkRecord: Codable, Equatable, Sendable {-    public let id: UUID-    public let displayTitle: String-    public let lastParsedTitle: String?-    public let siteHostname: String-    public let urlIdentity: String?-    public let workURL: String?-    public let genericNotes: String-    public let type: WorkType-    public let genreTags: [String]-    public let titleProvenance: TitleProvenance-    public let createdAt: Date-    public let modifiedAt: Date-    public let entryIDs: [UUID]--    public init(-        id: UUID,-        displayTitle: String,-        lastParsedTitle: String?,-        siteHostname: String,-        urlIdentity: String?,-        workURL: String?,-        genericNotes: String,-        type: WorkType,-        genreTags: [String],-        titleProvenance: TitleProvenance,-        createdAt: Date,-        modifiedAt: Date,-        entryIDs: [UUID]-    ) {-        self.id = id-        self.displayTitle = displayTitle-        self.lastParsedTitle = lastParsedTitle-        self.siteHostname = siteHostname-        self.urlIdentity = urlIdentity-        self.workURL = workURL-        self.genericNotes = genericNotes-        self.type = type-        self.genreTags = genreTags-        self.titleProvenance = titleProvenance-        self.createdAt = createdAt-        self.modifiedAt = modifiedAt-        self.entryIDs = entryIDs-    }-}--/// Frozen Site record from the M2.3 backup wire format.-public struct LegacyV2SiteRecord: Codable, Equatable, Sendable {-    public let hostname: String-    public let displayName: String-    public let mode: SiteMode-    public let patternIDs: [UUID]-    public let urlIdentityRule: URLIdentityRule?-    public let junkSuffixRule: JunkSuffixRule?--    public init(-        hostname: String,-        displayName: String,-        mode: SiteMode,-        patternIDs: [UUID],-        urlIdentityRule: URLIdentityRule?,-        junkSuffixRule: JunkSuffixRule?-    ) {-        self.hostname = hostname-        self.displayName = displayName-        self.mode = mode-        self.patternIDs = patternIDs-        self.urlIdentityRule = urlIdentityRule-        self.junkSuffixRule = junkSuffixRule-    }-}--/// Frozen TitlePattern record from the M2.3 backup wire format.-public struct LegacyV2TitlePatternRecord: Codable, Equatable, Sendable {-    public let id: UUID-    public let version: Int-    public let isActive: Bool-    public let createdAt: Date-    public let definition: PatternDefinition-    public let siteHostname: String--    public init(-        id: UUID,-        version: Int,-        isActive: Bool,-        createdAt: Date,-        definition: PatternDefinition,-        siteHostname: String-    ) {-        self.id = id-        self.version = version-        self.isActive = isActive-        self.createdAt = createdAt-        self.definition = definition-        self.siteHostname = siteHostname-    }-}
Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift Deleted +0 / -231
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swiftdeleted file mode 100644index 8d6d915..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/M2PerformanceFixture.swift+++ /dev/null@@ -1,231 +0,0 @@-#if DEBUG || ASTERISM_PERFORMANCE_TESTING-import Foundation-import SwiftData--extension LibraryRepository {-    /// Seeds Requirement 10's exact deterministic data shape into a fresh test library.-    ///-    /// This API is compiled only for Development or explicit Release performance-test-    /// builds. It performs one guarded save so interrupted setup cannot expose a-    /// partially populated fixture to the measured app launch.-    public func seedM2PerformanceFixture() async throws {-        let entryCounts = [-            "scale.test": 5_000,-            "archive.test": 4_200,-            "articles.test": 3_600,-            "serial.test": 3_000,-            "notes.test": 2_400,-            "essays.test": 1_800,-        ]-        let workCounts = [-            "scale.test": 500,-            "archive.test": 400,-            "articles.test": 350,-            "serial.test": 300,-            "notes.test": 250,-            "essays.test": 200,-        ]-        guard entryCounts.values.reduce(0, +) == 20_000,-              workCounts.values.reduce(0, +) == 2_000,-              entryCounts.values.count(where: { $0 == 5_000 }) == 1 else {-            throw LibraryRepositoryError.invalidInput(-                operation: "seeding M2 performance fixture",-                reason: "fixture cardinalities do not match Requirement 10"-            )-        }-        // Guard on the features the fixture actually writes, not on the gate that-        // happened to be current when it was written. A `== .m2_3` identity check-        // silently killed this fixture at the .m3 bump and again at .m4 — the-        // seeder threw, the only UI suite driving it was device-only, and the-        // failure went unseen for two milestones. Re-pinning to `== .m4` would-        // fail the same way at the next bump.-        //-        // The graph below needs segment title rules and a Site in Articles mode,-        // and nothing else that is gated. Be clear about which half bites:-        // `allows(patternForm: .segment)` is unconditionally true at every gate-        // (`AsterismCapabilities.swift`, `case .segment: true`), so-        // `supportsArticles` is the only condition that can currently fail. The-        // segment clause is kept because it states the fixture's real requirement-        // — if a future gate ever does drop segment rules, this guard already-        // says so rather than throwing somewhere less obvious.-        guard capabilities.allows(patternForm: .segment), capabilities.supportsArticles else {-            throw LibraryRepositoryError.invalidInput(-                operation: "seeding M2 performance fixture",-                reason: "the complete scale fixture requires segment teaching and Articles mode"-            )-        }--        try await withLockedContext(-            mode: .exclusive,-            operation: "seeding M2 performance fixture"-        ) { context in-            let existingCount = try context.fetchCount(FetchDescriptor<Entry>())-                + context.fetchCount(FetchDescriptor<Work>())-                + context.fetchCount(FetchDescriptor<Site>())-                + context.fetchCount(FetchDescriptor<TitlePattern>())-            guard existingCount == 0 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M2 performance fixture",-                    reason: "destination library is not empty"-                )-            }--            let targetHostname = "scale.test"-            let articleHostname = "articles.test"-            let historicalPatternID = Self.performanceUUID(namespace: 1, index: 1)-            let activePatternID = Self.performanceUUID(namespace: 1, index: 2)-            let definition = PatternDefinition.segment(-                work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),-                ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]-            )--            var sitesByHostname: [String: Site] = [:]-            for hostname in entryCounts.keys.sorted() {-                let site = Site(hostname: hostname)-                if hostname == targetHostname {-                    site.mode = .taught-                    context.insert(-                        try TitlePattern(-                            id: historicalPatternID,-                            version: 1,-                            isActive: false,-                            createdAt: Date(timeIntervalSince1970: 1),-                            definition: definition,-                            site: site-                        )-                    )-                    context.insert(-                        try TitlePattern(-                            id: activePatternID,-                            version: 2,-                            isActive: true,-                            createdAt: Date(timeIntervalSince1970: 2),-                            definition: definition,-                            site: site-                        )-                    )-                } else if hostname == articleHostname {-                    site.mode = .articles-                }-                context.insert(site)-                sitesByHostname[hostname] = site-            }--            var worksByHostname: [String: [Work]] = [:]-            var globalWorkIndex = 0-            for hostname in workCounts.keys.sorted() {-                guard sitesByHostname[hostname] != nil,-                      let count = workCounts[hostname], count > 0 else {-                    throw LibraryRepositoryError.invalidInput(-                        operation: "seeding M2 performance fixture",-                        reason: "missing or empty Work distribution for '\(hostname)'"-                    )-                }-                var siteWorks: [Work] = []-                siteWorks.reserveCapacity(count)-                for localIndex in 0..<count {-                    let title = "Work \(localIndex)"-                    let work = Work(-                        id: Self.performanceUUID(namespace: 2, index: globalWorkIndex),-                        displayTitle: title,-                        siteHostname: hostname,-                        timestamp: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex))-                    )-                    work.titleProvenance = localIndex.isMultiple(of: 3) ? .manual : .parsed-                    work.lastParsedTitle = localIndex.isMultiple(of: 2) ? title : nil-                    context.insert(work)-                    // Both halves, as every write path sets them (Req 1.4). The-                    // fixture is guarded on an empty store, so this map holds-                    // exactly one row per hostname.-                    work.site = sitesByHostname[hostname]-                    siteWorks.append(work)-                    globalWorkIndex += 1-                }-                worksByHostname[hostname] = siteWorks-            }--            var globalEntryIndex = 0-            for hostname in entryCounts.keys.sorted() {-                guard let count = entryCounts[hostname], count > 0,-                      let siteWorks = worksByHostname[hostname], !siteWorks.isEmpty else {-                    throw LibraryRepositoryError.invalidInput(-                        operation: "seeding M2 performance fixture",-                        reason: "missing or empty Entry distribution for '\(hostname)'"-                    )-                }-                for localIndex in 0..<count {-                    let timestamp = Date(timeIntervalSince1970: TimeInterval(globalEntryIndex))-                    let isTarget = hostname == targetHostname-                    let state = isTarget ? localIndex % 4 : 2-                    let assignedWork = state == 0 || state == 1-                        ? siteWorks[localIndex % siteWorks.count]-                        : nil-                    let entry = Entry(-                        id: Self.performanceUUID(namespace: 3, index: globalEntryIndex),-                        captureTitle: "Chapter \(localIndex) - Work \(localIndex % siteWorks.count) | \(hostname)",-                        captureTitleSource: localIndex.isMultiple(of: 2) ? .host : .manual,-                        rawURLString: "https://\(hostname)/entry/\(localIndex)",-                        hostname: hostname,-                        entryIdentityKey: "https://\(hostname)/entry/\(localIndex)",-                        timestamp: timestamp,-                        note: localIndex.isMultiple(of: 17) ? "Representative note" : "",-                        rating: localIndex.isMultiple(of: 19) ? .up : nil,-                        work: assignedWork-                    )--                    switch state {-                    case 0:-                        entry.chapterTitle = "Chapter \(localIndex)"-                        entry.chapterTitleProvenance = .manual-                        entry.workAssignmentProvenance = .manual-                    case 1:-                        entry.chapterTitle = "Chapter \(localIndex)"-                        entry.chapterTitleProvenance = .pattern-                        entry.chapterPatternID = historicalPatternID-                        entry.chapterPatternVersion = 1-                        entry.workAssignmentProvenance = .pattern-                        entry.workPatternID = historicalPatternID-                        entry.workPatternVersion = 1-                    case 3:-                        entry.chapterTitle = "Chapter \(localIndex)"-                        entry.chapterTitleProvenance = .pattern-                        entry.chapterPatternID = activePatternID-                        entry.chapterPatternVersion = 2-                        entry.workAssignmentProvenance = .pattern-                        entry.workPatternID = activePatternID-                        entry.workPatternVersion = 2-                    default:-                        if hostname == articleHostname {-                            entry.intentionallyUnattached = true-                        }-                    }-                    context.insert(entry)-                    entry.site = sitesByHostname[hostname]-                    globalEntryIndex += 1-                }-            }--            guard try context.fetchCount(FetchDescriptor<Entry>()) == 20_000,-                  try context.fetchCount(FetchDescriptor<Work>()) == 2_000,-                  try context.fetchCount(FetchDescriptor<Site>()) == entryCounts.count,-                  try context.fetchCount(FetchDescriptor<TitlePattern>()) == 2 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M2 performance fixture",-                    reason: "generated model counts differ from the requested fixture"-                )-            }-            try saveStrategy.save(context)-        }-    }--    private static func performanceUUID(namespace: Int, index: Int) -> UUID {-        let value = String(format: "%012llX", UInt64(index))-        guard let id = UUID(-            uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)-        ) else {-            preconditionFailure("The deterministic performance UUID format must remain valid")-        }-        return id-    }-}-#endif
Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift Deleted +0 / -410
diff --git a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift b/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swiftdeleted file mode 100644index bc5a592..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/M3PerformanceFixture.swift+++ /dev/null@@ -1,410 +0,0 @@-#if DEBUG || ASTERISM_PERFORMANCE_TESTING-import Foundation-import SwiftData--extension LibraryRepository {-    /// Seeds the exact M3 URL-identity deterministic data shape into a fresh V3 library.-    ///-    /// This produces exactly 5,000 Site Entries for one URL-taught Site with the-    /// Requirement 7.5 distribution:-    /// - 3,000 separate-component bracket successes-    /// - 1,000 combined-template successes-    /// - 400 extraction failures-    /// - 300 in Work-collision groups-    /// - 300 in Work-split groups-    /// - 100 identity-key collision pairs among successful groups-    ///-    /// Compiled only for Development or explicit Release performance-test builds.-    /// Performs one guarded save so interrupted setup cannot expose a partially-    /// populated fixture to the measured app launch.-    public func seedM3PerformanceFixture() async throws {-        guard capabilities == .m3 else {-            throw LibraryRepositoryError.invalidInput(-                operation: "seeding M3 performance fixture",-                reason: "the M3 URL-identity fixture requires the M3 capability gate"-            )-        }--        try await withLockedContext(-            mode: .exclusive,-            operation: "seeding M3 performance fixture"-        ) { context in-            let existingCount = try context.fetchCount(FetchDescriptor<Entry>())-                + context.fetchCount(FetchDescriptor<Work>())-                + context.fetchCount(FetchDescriptor<Site>())-            guard existingCount == 0 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M3 performance fixture",-                    reason: "destination library is not empty"-                )-            }--            let hostname = "scale.test"-            let ruleID = Self.m3FixtureUUID(namespace: 20, index: 0)--            // Create an ordinary taught Site with an active title pattern and a-            // current URL rule.-            //-            // This is the fixture's only Site row, and every Work and Entry the-            // phases below build carries `site` in the same save the hostname-            // string is written in (Req 1.4). Without those assignments the-            // scale suites would measure a graph with no relationships at all —-            // one the app cannot produce.-            let site = Site(hostname: hostname)-            site.mode = .taught-            context.insert(site)--            // Create the current bracket rule: .workAndSequence-            let bracketRule = URLRuleDefinition.workAndSequence(-                work: URLFieldSelector(-                    locator: .pathBracketed(-                        left: .literal(ExactScalarString("series")),-                        right: .literal(ExactScalarString("chapter"))-                    )-                ),-                sequence: URLFieldSelector(-                    locator: .pathBracketed(-                        left: .literal(ExactScalarString("chapter")),-                        right: .end-                    )-                )-            )-            let ruleVersion = 1-            let urlRule = try URLRulePattern(-                id: ruleID,-                version: ruleVersion,-                isCurrent: true,-                createdAt: Date(timeIntervalSince1970: 1),-                origin: .readerTaught,-                definition: bracketRule,-                site: site-            )-            context.insert(urlRule)--            // Also add an active title pattern (required for ordinary taught Sites)-            let titlePatternID = Self.m3FixtureUUID(namespace: 20, index: 1)-            let titleDef = PatternDefinition.segment(-                work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),-                ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]-            )-            let titlePattern = try TitlePattern(-                id: titlePatternID,-                version: 1,-                isActive: true,-                createdAt: Date(timeIntervalSince1970: 1),-                definition: titleDef,-                site: site-            )-            context.insert(titlePattern)--            // --- 1. Bracket successes (3,000 entries, 600 Works × 5 Entries) ----            for workIndex in 0..<600 {-                let workID = Self.m3FixtureUUID(namespace: 10, index: workIndex)-                let workIdentity = "work\(workIndex)"-                let work = Work(-                    id: workID,-                    displayTitle: "Work \(workIndex)",-                    siteHostname: hostname,-                    timestamp: Date(timeIntervalSince1970: TimeInterval(workIndex))-                )-                work.titleProvenance = .parsed-                work.lastParsedTitle = "Work \(workIndex)"-                work.urlIdentity = workIdentity-                work.urlIdentityState = .rule-                work.urlIdentityRuleID = ruleID-                work.urlIdentityRuleVersion = ruleVersion-                context.insert(work)-                work.site = site--                for seqIndex in 0..<5 {-                    let entryIndex = workIndex * 5 + seqIndex-                    let seqVal = "\(seqIndex + 1)"-                    var rawURLString = "https://scale.test/series/\(workIdentity)/chapter/\(seqVal)"--                    // Key-collision pairs: for workIndex 560–599, override pairs-                    // to share the same work+seq (differ only in query)-                    let isKeyCollisionEntry = entryIndex >= 2800-                    if isKeyCollisionEntry {-                        let pairIndex = (entryIndex - 2800) / 2-                        let pairWorkIdx = 560 + pairIndex / 2-                        let pairSeqVal = (pairIndex % 2) + 1-                        let suffix = (entryIndex - 2800) % 2 == 0 ? "a" : "b"-                        rawURLString = "https://scale.test/series/work\(pairWorkIdx)/chapter/\(pairSeqVal)?src=\(suffix)\(pairIndex)"-                    }--                    let identity = try URLDerivedEntryIdentity(-                        hostname: ExactScalarString(hostname),-                        workIdentity: isKeyCollisionEntry-                            ? ExactScalarString("work\(560 + ((entryIndex - 2800) / 2) / 2)")-                            : ExactScalarString(workIdentity),-                        chapterSequence: isKeyCollisionEntry-                            ? ExactScalarString("\(((entryIndex - 2800) / 2) % 2 + 1)")-                            : ExactScalarString(seqVal)-                    )-                    let identityKey = EntryIdentityKeyV2Codec.encode(identity)--                    let entry = Entry(-                        id: Self.m3FixtureUUID(namespace: 11, index: entryIndex),-                        captureTitle: "Work \(workIndex) Ch \(seqIndex + 1) | scale.test",-                        captureTitleSource: .host,-                        rawURLString: rawURLString,-                        hostname: hostname,-                        entryIdentityKey: identityKey,-                        timestamp: Date(timeIntervalSince1970: TimeInterval(entryIndex)),-                        work: work-                    )-                    entry.identityBasis = .urlRule-                    entry.identityURLRuleID = ruleID-                    entry.identityURLRuleVersion = ruleVersion-                    entry.urlWorkIdentity = isKeyCollisionEntry-                        ? "work\(560 + ((entryIndex - 2800) / 2) / 2)"-                        : workIdentity-                    entry.urlWorkRuleID = ruleID-                    entry.urlWorkRuleVersion = ruleVersion-                    entry.chapterSequence = isKeyCollisionEntry-                        ? "\(((entryIndex - 2800) / 2) % 2 + 1)"-                        : seqVal-                    entry.chapterSequenceRuleID = ruleID-                    entry.chapterSequenceRuleVersion = ruleVersion-                    entry.workURLRuleID = ruleID-                    entry.workURLRuleVersion = ruleVersion-                    entry.workURLAssignmentKind = .identity-                    entry.workAssignmentProvenance = .urlRule-                    context.insert(entry)-                    entry.site = site-                }-            }--            // --- 2. Combined-template successes (1,000 entries, 200 Works × 5 Entries) ----            // These use a different URL pattern that doesn't match the bracket rule-            // but we store them with the bracket rule provenance as they're part of the-            // fixture's scale validation (the teaching preview must process all 5,000).-            // In practice, the template entries use conservative keys since the current-            // rule is the bracket rule.-            for workIndex in 0..<200 {-                let workID = Self.m3FixtureUUID(namespace: 12, index: workIndex)-                let work = Work(-                    id: workID,-                    displayTitle: "Mixed \(workIndex)",-                    siteHostname: hostname,-                    timestamp: Date(timeIntervalSince1970: TimeInterval(600 + workIndex))-                )-                work.titleProvenance = .parsed-                work.lastParsedTitle = "Mixed \(workIndex)"-                // Template entries don't match the bracket rule, so Work has no URL identity-                work.urlIdentity = nil-                work.urlIdentityState = .none-                context.insert(work)-                work.site = site--                for seqIndex in 0..<5 {-                    let entryIndex = workIndex * 5 + seqIndex-                    let rawURLString = "https://scale.test/content/mixed/w\(workIndex)-ch\(seqIndex + 1)"--                    let entry = Entry(-                        id: Self.m3FixtureUUID(namespace: 13, index: entryIndex),-                        captureTitle: "Mixed \(workIndex) Ch \(seqIndex + 1) | scale.test",-                        captureTitleSource: .host,-                        rawURLString: rawURLString,-                        hostname: hostname,-                        entryIdentityKey: rawURLString, // conservative key-                        timestamp: Date(timeIntervalSince1970: TimeInterval(3_000 + entryIndex)),-                        work: work-                    )-                    // Conservative identity (template URLs don't match the bracket rule)-                    entry.identityBasis = .conservative-                    entry.workAssignmentProvenance = .pattern-                    context.insert(entry)-                    entry.site = site-                }-            }--            // --- 3. Extraction failures (400 entries, no Work) ----            for failIndex in 0..<400 {-                let rawURLString: String-                switch failIndex % 5 {-                case 0:-                    rawURLString = "https://scale.test/posts/article\(failIndex)/page/\(failIndex)"-                case 1:-                    rawURLString = "https://scale.test/series/work\(failIndex)/page/\(failIndex)"-                case 2:-                    rawURLString = "https://scale.test/series//chapter/\(failIndex)"-                case 3:-                    rawURLString = "https://scale.test/series/x\(failIndex)/chapter/series/y\(failIndex)/chapter/z\(failIndex)"-                default:-                    rawURLString = "https://scale.test/series"-                }--                let entry = Entry(-                    id: Self.m3FixtureUUID(namespace: 14, index: failIndex),-                    captureTitle: "Failure \(failIndex) | scale.test",-                    captureTitleSource: .host,-                    rawURLString: rawURLString,-                    hostname: hostname,-                    entryIdentityKey: rawURLString, // conservative key-                    timestamp: Date(timeIntervalSince1970: TimeInterval(4_000 + failIndex))-                )-                entry.identityBasis = .conservative-                context.insert(entry)-                entry.site = site-            }--            // --- 4. Collision entries (300 entries, 30 groups × 2 Works × 5 Entries) ----            for groupIndex in 0..<30 {-                for workOffset in 0..<2 {-                    let workID = Self.m3FixtureUUID(namespace: 15, index: groupIndex * 2 + workOffset)-                    let sharedIdentity = "collision\(groupIndex)"-                    let work = Work(-                        id: workID,-                        displayTitle: "Collision \(groupIndex) W\(workOffset)",-                        siteHostname: hostname,-                        timestamp: Date(timeIntervalSince1970: TimeInterval(800 + groupIndex * 2 + workOffset))-                    )-                    work.titleProvenance = .parsed-                    work.lastParsedTitle = "Collision \(groupIndex) W\(workOffset)"-                    work.urlIdentity = sharedIdentity-                    work.urlIdentityState = .rule-                    work.urlIdentityRuleID = ruleID-                    work.urlIdentityRuleVersion = ruleVersion-                    context.insert(work)-                    work.site = site--                    for entryOffset in 0..<5 {-                        let entryIndex = groupIndex * 10 + workOffset * 5 + entryOffset-                        let seqVal = "c\(entryIndex)"-                        let rawURLString = "https://scale.test/series/\(sharedIdentity)/chapter/\(seqVal)"--                        let identity = try URLDerivedEntryIdentity(-                            hostname: ExactScalarString(hostname),-                            workIdentity: ExactScalarString(sharedIdentity),-                            chapterSequence: ExactScalarString(seqVal)-                        )-                        let identityKey = EntryIdentityKeyV2Codec.encode(identity)--                        let entry = Entry(-                            id: Self.m3FixtureUUID(namespace: 16, index: entryIndex),-                            captureTitle: "Collision \(groupIndex) Entry \(entryIndex) | scale.test",-                            captureTitleSource: .host,-                            rawURLString: rawURLString,-                            hostname: hostname,-                            entryIdentityKey: identityKey,-                            timestamp: Date(timeIntervalSince1970: TimeInterval(4_400 + entryIndex)),-                            work: work-                        )-                        entry.identityBasis = .urlRule-                        entry.identityURLRuleID = ruleID-                        entry.identityURLRuleVersion = ruleVersion-                        entry.urlWorkIdentity = sharedIdentity-                        entry.urlWorkRuleID = ruleID-                        entry.urlWorkRuleVersion = ruleVersion-                        entry.chapterSequence = seqVal-                        entry.chapterSequenceRuleID = ruleID-                        entry.chapterSequenceRuleVersion = ruleVersion-                        entry.workURLRuleID = ruleID-                        entry.workURLRuleVersion = ruleVersion-                        entry.workURLAssignmentKind = .identity-                        entry.workAssignmentProvenance = .urlRule-                        context.insert(entry)-                        entry.site = site-                    }-                }-            }--            // --- 5. Split entries (300 entries, 30 groups × 10 Entries per Work) ----            for groupIndex in 0..<30 {-                let workID = Self.m3FixtureUUID(namespace: 17, index: groupIndex)-                let work = Work(-                    id: workID,-                    displayTitle: "Split \(groupIndex)",-                    siteHostname: hostname,-                    timestamp: Date(timeIntervalSince1970: TimeInterval(860 + groupIndex))-                )-                work.titleProvenance = .parsed-                work.lastParsedTitle = "Split \(groupIndex)"-                // Split Works have no URL identity (Requirement 3.4)-                work.urlIdentity = nil-                work.urlIdentityState = .none-                context.insert(work)-                work.site = site--                for entryOffset in 0..<10 {-                    let entryIndex = groupIndex * 10 + entryOffset-                    // First 5 yield identityA, last 5 yield identityB-                    let identity = entryOffset < 5 ? "splitA\(groupIndex)" : "splitB\(groupIndex)"-                    let seqVal = "s\(entryIndex)"-                    let rawURLString = "https://scale.test/series/\(identity)/chapter/\(seqVal)"--                    let derivedIdentity = try URLDerivedEntryIdentity(-                        hostname: ExactScalarString(hostname),-                        workIdentity: ExactScalarString(identity),-                        chapterSequence: ExactScalarString(seqVal)-                    )-                    let identityKey = EntryIdentityKeyV2Codec.encode(derivedIdentity)--                    let entry = Entry(-                        id: Self.m3FixtureUUID(namespace: 18, index: entryIndex),-                        captureTitle: "Split \(groupIndex) Entry \(entryOffset) | scale.test",-                        captureTitleSource: .host,-                        rawURLString: rawURLString,-                        hostname: hostname,-                        entryIdentityKey: identityKey,-                        timestamp: Date(timeIntervalSince1970: TimeInterval(4_700 + entryIndex)),-                        work: work-                    )-                    entry.identityBasis = .urlRule-                    entry.identityURLRuleID = ruleID-                    entry.identityURLRuleVersion = ruleVersion-                    entry.urlWorkIdentity = identity-                    entry.urlWorkRuleID = ruleID-                    entry.urlWorkRuleVersion = ruleVersion-                    entry.chapterSequence = seqVal-                    entry.chapterSequenceRuleID = ruleID-                    entry.chapterSequenceRuleVersion = ruleVersion-                    entry.workURLRuleID = ruleID-                    entry.workURLRuleVersion = ruleVersion-                    entry.workURLAssignmentKind = .identity-                    entry.workAssignmentProvenance = .urlRule-                    context.insert(entry)-                    entry.site = site-                }-            }--            // Final count validation-            let finalEntries = try context.fetchCount(FetchDescriptor<Entry>())-            let finalWorks = try context.fetchCount(FetchDescriptor<Work>())-            let finalSites = try context.fetchCount(FetchDescriptor<Site>())-            guard finalEntries == 5_000 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M3 performance fixture",-                    reason: "expected 5,000 entries but inserted \(finalEntries)"-                )-            }-            // 600 bracket + 200 template + 60 collision + 30 split = 890 Works-            guard finalWorks == 890 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M3 performance fixture",-                    reason: "expected 890 works but inserted \(finalWorks)"-                )-            }-            guard finalSites == 1 else {-                throw LibraryRepositoryError.invalidInput(-                    operation: "seeding M3 performance fixture",-                    reason: "expected 1 site but inserted \(finalSites)"-                )-            }--            try saveStrategy.save(context)-        }-    }--    internal static func m3FixtureUUID(namespace: Int, index: Int) -> UUID {-        let value = String(format: "%012llX", UInt64(index))-        guard let id = UUID(-            uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)-        ) else {-            preconditionFailure("The deterministic M3 performance UUID format must remain valid")-        }-        return id-    }-}-#endif
Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift Deleted +0 / -206
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift b/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swiftdeleted file mode 100644index c407961..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/V2MigrationStore.swift+++ /dev/null@@ -1,206 +0,0 @@-import Foundation-import SwiftData--/// Raw V2 store operations used only by the explicit developer migration path.-/// They intentionally do not write runtime readiness; the app owns that step.-///-/// **The one write site that deliberately does not set `Entry.site` /-/// `Work.site`** (Req 1.4, Q45). Everything here happens inside a container-/// built from `Schema(versionedSchema: AsterismSchemaV2.self)` — a 2.0.0 stamp-/// over the *live* model classes — and `create` refuses to run unless the-/// destination does not exist, so the only store this type ever touches is one-/// it has just written itself and immediately reads back through-/// `readSnapshot`. That snapshot is hostname-keyed, the V2 store is a separate-/// file from the V4/V5 store, and nothing carries a V2-written relationship-/// forward. Setting the relationships here would write a V5-only column into a-/// store recorded at 2.0.0 that no reader will ever look at.-public enum V2MigrationStore {-    public static func artifactURLs(for storeURL: URL) -> [URL] {-        [-            storeURL,-            URL(filePath: storeURL.path + "-shm"),-            URL(filePath: storeURL.path + "-wal"),-            URL(filePath: storeURL.path + "-journal"),-        ]-    }--    public static func create(-        snapshot: LibraryBackupSnapshot,-        at storeURL: URL,-        capabilities: AsterismCapabilities-    ) throws {-        try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)-        guard !artifactURLs(for: storeURL).contains(where: {-            FileManager.default.fileExists(atPath: $0.path)-        }) else {-            throw LibraryRepositoryError.invalidInput(-                operation: "creating migrated V2 store",-                reason: "destination already exists"-            )-        }--        do {-            try FileManager.default.createDirectory(-                at: storeURL.deletingLastPathComponent(),-                withIntermediateDirectories: true-            )-            let container = try makeContainer(at: storeURL)-            let context = ModelContext(container)--            var sites: [String: Site] = [:]-            for record in snapshot.sites {-                let site = Site(hostname: record.hostname, displayName: record.displayName)-                site.modeRaw = record.mode.rawValue-                site.urlIdentityRule = record.urlIdentityRule-                site.junkSuffixRule = record.junkSuffixRule-                context.insert(site)-                sites[record.hostname] = site-            }--            for record in snapshot.titlePatterns {-                guard let site = sites[record.siteHostname] else {-                    throw LibraryRepositoryError.corruptLibrary(-                        operation: "materializing migrated TitlePattern",-                        reason: "missing Site \(record.siteHostname)"-                    )-                }-                context.insert(try TitlePattern(-                    id: record.id,-                    version: record.version,-                    isActive: record.isActive,-                    createdAt: record.createdAt,-                    definition: record.definition,-                    site: site-                ))-            }--            var works: [UUID: Work] = [:]-            for record in snapshot.works {-                let work = Work(-                    id: record.id,-                    displayTitle: record.displayTitle,-                    siteHostname: record.siteHostname,-                    timestamp: record.createdAt-                )-                work.lastParsedTitle = record.lastParsedTitle-                work.urlIdentity = record.urlIdentity-                work.workURLString = record.workURL-                work.genericNotes = record.genericNotes-                work.typeRaw = record.type.rawValue-                work.genreTags = record.genreTags-                work.titleProvenanceRaw = record.titleProvenance.rawValue-                work.createdAt = record.createdAt-                work.modifiedAt = record.modifiedAt-                context.insert(work)-                works[record.id] = work-            }--            for record in snapshot.entries {-                let entry = Entry(-                    id: record.id,-                    captureTitle: record.captureTitle,-                    captureTitleSource: record.captureTitleSource,-                    rawURLString: record.rawURL,-                    canonicalURLString: record.canonicalURL,-                    hostname: record.hostname,-                    entryIdentityKey: record.entryIdentityKey,-                    timestamp: record.firstCapturedAt,-                    note: record.note,-                    rating: record.rating,-                    work: try record.workID.map { workID in-                        guard let work = works[workID] else {-                            throw LibraryRepositoryError.recordNotFound(type: "Work", id: workID)-                        }-                        return work-                    }-                )-                entry.identityKeyVersion = record.identityKeyVersion-                entry.chapterTitle = record.chapterTitle-                entry.chapterTitleProvenanceRaw = record.chapterTitleProvenance.kind.rawValue-                entry.chapterPatternID = record.chapterTitleProvenance.patternID-                entry.chapterPatternVersion = record.chapterTitleProvenance.patternVersion-                entry.firstCapturedAt = record.firstCapturedAt-                entry.lastSharedAt = record.lastSharedAt-                entry.modifiedAt = record.modifiedAt-                entry.workAssignmentProvenanceRaw = record.workAssignmentProvenance.kind.rawValue-                entry.workPatternID = record.workAssignmentProvenance.patternID-                entry.workPatternVersion = record.workAssignmentProvenance.patternVersion-                entry.intentionallyUnattached = record.intentionallyUnattached-                context.insert(entry)-            }-            try context.save()-        } catch {-            cleanup(storeURL: storeURL)-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "materializing migrated V2 graph",-                reason: String(describing: error)-            )-        }-    }--    public static func readSnapshot(-        from storeURL: URL,-        capabilities: AsterismCapabilities-    ) throws -> LibraryBackupSnapshot {-        guard FileManager.default.fileExists(atPath: storeURL.path) else {-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "reopening migrated V2 store",-                reason: "destination store is missing"-            )-        }-        do {-            let container = try makeContainer(at: storeURL)-            let context = ModelContext(container)-            let entries = try context.fetch(FetchDescriptor<Entry>())-            let works = try context.fetch(FetchDescriptor<Work>())-            let sites = try context.fetch(FetchDescriptor<Site>())-            let patterns = try context.fetch(FetchDescriptor<TitlePattern>())-            let snapshot = LibraryBackupSnapshot(-                entries: try entries.map(LibraryRepository.mapEntryRecord)-                    .sorted { uuidOrder($0.id, $1.id) },-                works: try works.map(LibraryRepository.mapWorkRecord)-                    .sorted { uuidOrder($0.id, $1.id) },-                sites: try sites.map(LibraryRepository.mapSiteRecord)-                    .sorted { $0.hostname < $1.hostname },-                titlePatterns: try patterns.map(LibraryRepository.mapTitlePatternRecord)-                    .sorted { uuidOrder($0.id, $1.id) }-            )-            try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)-            return snapshot-        } catch let error as LibraryRepositoryError {-            throw error-        } catch let error as BackupValidationError {-            throw error-        } catch {-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "reopening and verifying migrated V2 store",-                reason: String(describing: error)-            )-        }-    }--    public static func cleanup(storeURL: URL) {-        for url in artifactURLs(for: storeURL) {-            try? FileManager.default.removeItem(at: url)-        }-    }--    private static func makeContainer(at storeURL: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV2.self)-        let configuration = ModelConfiguration(-            "AsterismV2",-            schema: schema,-            url: storeURL,-            cloudKitDatabase: .none-        )-        return try ModelContainer(-            for: schema,-            migrationPlan: AsterismV2MigrationPlan.self,-            configurations: [configuration]-        )-    }--    private static func uuidOrder(_ left: UUID, _ right: UUID) -> Bool {-        left.uuidString.lowercased() < right.uuidString.lowercased()-    }-}
Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift Deleted +0 / -101
diff --git a/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift b/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swiftdeleted file mode 100644index 102301c..0000000--- a/Packages/AsterismCore/Sources/AsterismMigrationTool/main.swift+++ /dev/null@@ -1,101 +0,0 @@-import AsterismCore-import AsterismV1MigrationSupport-import Foundation--@main-enum AsterismMigrationTool {-    static func main() async {-        do {-            let options = try Options(arguments: Array(CommandLine.arguments.dropFirst()))-            let configuration = LibraryConfiguration(rootDirectory: options.rootDirectory)--            try await V1ToV2Migrator.migrate(-                configuration: configuration,-                processChecker: SystemRuntimeProcessChecker(),-                verifier: LogicalMigrationVerifier(),-                capabilities: .m2_0-            )-            print("Migrated V1 to verified V2 at \(configuration.storeURL.path)")-            print("Open Asterism to validate the store and write runtime readiness.")-        } catch {-            FileHandle.standardError.write(-                Data("migrate-m1-to-m2: \(error)\n".utf8)-            )-            exit(1)-        }-    }-}--/// `--root` is required (Q15). The tool's only other way to find a library was to-/// resolve the host Mac's App Group container from a compiled-in identifier — a-/// path nothing needs once the identifier lives in the app's bundle, and one the-/// tool could not read anyway.-private struct Options {-    let rootDirectory: URL--    init(arguments: [String]) throws {-        var root: URL?-        var index = 0-        while index < arguments.count {-            switch arguments[index] {-            case "--root":-                index += 1-                guard index < arguments.count, !arguments[index].isEmpty else {-                    throw ToolError.invalidArguments("--root requires an absolute directory path")-                }-                let candidate = URL(filePath: arguments[index], directoryHint: .isDirectory)-                guard candidate.path.hasPrefix("/") else {-                    throw ToolError.invalidArguments("--root must be absolute")-                }-                root = candidate-            default:-                throw ToolError.invalidArguments("unknown argument \(arguments[index])")-            }-            index += 1-        }-        guard let root else {-            throw ToolError.invalidArguments("--root is required: pass the library's root directory")-        }-        rootDirectory = root-    }-}--private struct SystemRuntimeProcessChecker: MigrationRuntimeProcessChecking {-    func requireRuntimeProcessesClosed() throws {-        for processName in ["Asterism", "AsterismShareExtension"] {-            let process = Process()-            process.executableURL = URL(filePath: "/usr/bin/pgrep")-            process.arguments = ["-x", processName]-            process.standardOutput = FileHandle.nullDevice-            process.standardError = FileHandle.nullDevice-            do {-                try process.run()-                process.waitUntilExit()-            } catch {-                throw ToolError.processCheckFailed(-                    "could not inspect \(processName): \(error)"-                )-            }-            if process.terminationStatus == 0 {-                throw V1ToV2MigrationError.runtimeProcessesOpen-            }-            guard process.terminationStatus == 1 else {-                throw ToolError.processCheckFailed(-                    "pgrep failed for \(processName) with status \(process.terminationStatus)"-                )-            }-        }-    }-}--private enum ToolError: Error, CustomStringConvertible {-    case invalidArguments(String)-    case processCheckFailed(String)--    var description: String {-        switch self {-        case .invalidArguments(let reason): "Invalid arguments: \(reason)"-        case .processCheckFailed(let reason): "Runtime process check failed: \(reason)"-        }-    }-}
Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swift Deleted +0 / -146
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swiftdeleted file mode 100644index 1458b1a..0000000--- a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1Models.swift+++ /dev/null@@ -1,146 +0,0 @@-import AsterismCore-import Foundation-import SwiftData--/// Frozen V1 schema used only by the explicit developer migration target.-public enum AsterismSchemaV1: VersionedSchema {-    public static let versionIdentifier = Schema.Version(1, 0, 0)--    public static var models: [any PersistentModel.Type] {-        [Entry.self, Work.self, Site.self, TitlePattern.self]-    }-}--public enum AsterismV1MigrationPlan: SchemaMigrationPlan {-    public static var schemas: [any VersionedSchema.Type] { [AsterismSchemaV1.self] }-    public static var stages: [MigrationStage] { [] }-}--@Model-public final class Entry {-    public var id: UUID = UUID()-    public var captureTitle: String = ""-    public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue-    public var rawURLString: String = ""-    public var canonicalURLString: String?-    public var hostname: String = ""-    public var entryIdentityKey: String = ""-    public var identityKeyVersion: Int = 1-    public var 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 intentionallyUnattached: Bool = false--    public init(-        id: UUID = UUID(),-        captureTitle: String,-        captureTitleSourceRaw: String,-        rawURLString: String,-        canonicalURLString: String? = nil,-        hostname: String,-        entryIdentityKey: String,-        timestamp: Date,-        note: String = "",-        ratingRaw: String? = nil,-        work: Work? = nil-    ) {-        self.id = id-        self.captureTitle = captureTitle-        self.captureTitleSourceRaw = captureTitleSourceRaw-        self.rawURLString = rawURLString-        self.canonicalURLString = canonicalURLString-        self.hostname = hostname-        self.entryIdentityKey = entryIdentityKey-        self.note = note-        self.ratingRaw = ratingRaw-        firstCapturedAt = timestamp-        lastSharedAt = timestamp-        modifiedAt = timestamp-        self.work = work-    }-}--@Model-public final class Work {-    public var id: UUID = UUID()-    public var displayTitle: String = ""-    public var lastParsedTitle: String?-    public var siteHostname: String = ""-    public var urlIdentity: String?-    public var 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(id: UUID = UUID(), displayTitle: String, siteHostname: String, timestamp: Date) {-        self.id = id-        self.displayTitle = displayTitle-        self.siteHostname = siteHostname-        createdAt = timestamp-        modifiedAt = timestamp-    }--    public var entryValues: [Entry] { entries ?? [] }-}--@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]?-    public var urlIdentityRule: URLIdentityRule?-    public var junkSuffixRule: JunkSuffixRule?--    public init(hostname: String, displayName: String? = nil) {-        self.hostname = hostname-        self.displayName = displayName ?? hostname-    }--    public var patternValues: [TitlePattern] { patterns ?? [] }-}--@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 workAnchor: SegmentRangeSpec = try! SegmentRangeSpec(origin: .start, offset: 0, length: 1)-    public var junkAnchors: [SegmentPositionSpec] = []-    public var site: Site?--    public init(-        id: UUID = UUID(),-        version: Int,-        isActive: Bool = false,-        createdAt: Date,-        workAnchor: SegmentRangeSpec,-        junkAnchors: [SegmentPositionSpec] = [],-        site: Site? = nil-    ) {-        self.id = id-        self.version = version-        self.isActive = isActive-        self.createdAt = createdAt-        self.workAnchor = workAnchor-        self.junkAnchors = junkAnchors-        self.site = site-    }-}
Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swift Deleted +0 / -151
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swiftdeleted file mode 100644index 8b7e62b..0000000--- a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1StoreReader.swift+++ /dev/null@@ -1,151 +0,0 @@-import AsterismCore-import Foundation-import SwiftData--public enum V1StoreReader {-    public static func readSnapshot(from storeURL: URL) throws -> LibraryBackupSnapshot {-        guard FileManager.default.fileExists(atPath: storeURL.path) else {-            throw V1ToV2MigrationError.sourceMissing(storeURL)-        }-        do {-            let schema = Schema(versionedSchema: AsterismSchemaV1.self)-            let configuration = ModelConfiguration(-                "AsterismV1",-                schema: schema,-                url: storeURL,-                cloudKitDatabase: .none-            )-            let container = try ModelContainer(-                for: schema,-                migrationPlan: AsterismV1MigrationPlan.self,-                configurations: [configuration]-            )-            let context = ModelContext(container)-            let entries = try context.fetch(FetchDescriptor<Entry>())-            let works = try context.fetch(FetchDescriptor<Work>())-            let sites = try context.fetch(FetchDescriptor<Site>())-            let patterns = try context.fetch(FetchDescriptor<TitlePattern>())--            return LibraryBackupSnapshot(-                entries: try entries.map(mapEntry).sorted(by: idOrder),-                works: try works.map(mapWork).sorted(by: idOrder),-                sites: try sites.map(mapSite).sorted { $0.hostname < $1.hostname },-                titlePatterns: try patterns.map(mapPattern).sorted(by: idOrder)-            )-        } catch let error as V1ToV2MigrationError {-            throw error-        } catch {-            throw V1ToV2MigrationError.readFailed(reason: String(describing: error))-        }-    }--    private static func mapEntry(_ entry: Entry) throws -> EntryRecord {-        guard let source = CaptureTitleSource(rawValue: entry.captureTitleSourceRaw) else {-            throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid capture title source")-        }-        let rating: Rating?-        if let raw = entry.ratingRaw {-            guard let value = Rating(rawValue: raw) else {-                throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid rating")-            }-            rating = value-        } else {-            rating = nil-        }-        guard let chapterKind = FieldProvenanceKind(rawValue: entry.chapterTitleProvenanceRaw),-              let assignmentKind = FieldProvenanceKind(rawValue: entry.workAssignmentProvenanceRaw) else {-            throw V1ToV2MigrationError.readFailed(reason: "Entry has invalid provenance kind")-        }-        return EntryRecord(-            id: entry.id,-            captureTitle: entry.captureTitle,-            captureTitleSource: source,-            rawURL: entry.rawURLString,-            canonicalURL: entry.canonicalURLString,-            hostname: entry.hostname,-            entryIdentityKey: entry.entryIdentityKey,-            identityKeyVersion: entry.identityKeyVersion,-            chapterTitle: entry.chapterTitle,-            chapterTitleProvenance: try FieldProvenance(-                kind: chapterKind,-                patternID: entry.chapterPatternID,-                patternVersion: entry.chapterPatternVersion-            ),-            note: entry.note,-            rating: rating,-            firstCapturedAt: entry.firstCapturedAt,-            lastSharedAt: entry.lastSharedAt,-            modifiedAt: entry.modifiedAt,-            workID: entry.work?.id,-            workAssignmentProvenance: try FieldProvenance(-                kind: assignmentKind,-                patternID: entry.workPatternID,-                patternVersion: entry.workPatternVersion-            ),-            intentionallyUnattached: entry.intentionallyUnattached-        )-    }--    private static func mapWork(_ work: Work) throws -> WorkRecord {-        guard let type = WorkType(rawValue: work.typeRaw),-              let provenance = TitleProvenance(rawValue: work.titleProvenanceRaw) else {-            throw V1ToV2MigrationError.readFailed(reason: "Work has invalid enum value")-        }-        return WorkRecord(-            id: work.id,-            displayTitle: work.displayTitle,-            lastParsedTitle: work.lastParsedTitle,-            siteHostname: work.siteHostname,-            urlIdentity: work.urlIdentity,-            workURL: work.workURLString,-            genericNotes: work.genericNotes,-            type: type,-            genreTags: work.genreTags,-            titleProvenance: provenance,-            createdAt: work.createdAt,-            modifiedAt: work.modifiedAt,-            entryIDs: work.entryValues.map(\.id).sorted(by: uuidOrder)-        )-    }--    private static func mapSite(_ site: Site) throws -> SiteRecord {-        guard let mode = SiteMode(rawValue: site.modeRaw) else {-            throw V1ToV2MigrationError.readFailed(reason: "Site has invalid mode")-        }-        return SiteRecord(-            hostname: site.hostname,-            displayName: site.displayName,-            mode: mode,-            patternIDs: site.patternValues.map(\.id).sorted(by: uuidOrder),-            urlIdentityRule: site.urlIdentityRule,-            junkSuffixRule: site.junkSuffixRule-        )-    }--    private static func mapPattern(_ pattern: TitlePattern) throws -> TitlePatternRecord {-        guard let hostname = pattern.site?.hostname else {-            throw V1ToV2MigrationError.readFailed(reason: "TitlePattern has no Site")-        }-        return TitlePatternRecord(-            id: pattern.id,-            version: pattern.version,-            isActive: pattern.isActive,-            createdAt: pattern.createdAt,-            definition: .segment(work: pattern.workAnchor, ignored: pattern.junkAnchors),-            siteHostname: hostname-        )-    }--    private static func idOrder<T>(_ left: T, _ right: T) -> Bool where T: MigrationIdentified {-        uuidOrder(left.id, right.id)-    }--    private static func uuidOrder(_ left: UUID, _ right: UUID) -> Bool {-        left.uuidString.lowercased() < right.uuidString.lowercased()-    }-}--private protocol MigrationIdentified { var id: UUID { get } }-extension EntryRecord: MigrationIdentified {}-extension WorkRecord: MigrationIdentified {}-extension TitlePatternRecord: MigrationIdentified {}
Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swift Deleted +0 / -172
diff --git a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swift b/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swiftdeleted file mode 100644index ce6f151..0000000--- a/Packages/AsterismCore/Sources/AsterismV1MigrationSupport/V1ToV2Migrator.swift+++ /dev/null@@ -1,172 +0,0 @@-import AsterismCore-import Foundation--public protocol MigrationRuntimeProcessChecking: Sendable {-    func requireRuntimeProcessesClosed() throws-}--protocol MigrationLockAcquiring: Sendable {-    func acquireExclusive(at url: URL, timeout: Duration) async throws -> LockLease-}--private struct CrossProcessMigrationLockAcquirer: MigrationLockAcquiring {-    func acquireExclusive(at url: URL, timeout: Duration) async throws -> LockLease {-        try await CrossProcessLibraryLock.acquire(mode: .exclusive, at: url, timeout: timeout)-    }-}--public protocol MigrationVerifying: Sendable {-    func verify(-        source: LibraryBackupSnapshot,-        destination: LibraryBackupSnapshot-    ) throws-}--public struct LogicalMigrationVerifier: MigrationVerifying {-    public init() {}--    public func verify(-        source: LibraryBackupSnapshot,-        destination: LibraryBackupSnapshot-    ) throws {-        guard source == destination else {-            throw V1ToV2MigrationError.verificationFailed(-                reason: "reopened destination differs from the V1 logical graph"-            )-        }-    }-}--public enum V1ToV2MigrationError: Error, Sendable, CustomStringConvertible {-    case runtimeProcessesOpen-    case sourceMissing(URL)-    case destinationExists(URL)-    case readFailed(reason: String)-    case verificationFailed(reason: String)-    case sourceChangedDuringMigration-    case operationFailed(operation: String, reason: String)--    public var description: String {-        switch self {-        case .runtimeProcessesOpen:-            "Asterism app or extension is running; close both before migration"-        case .sourceMissing(let url):-            "V1 source is missing at \(url.path)"-        case .destinationExists(let url):-            "V2 destination already exists at \(url.path)"-        case .readFailed(let reason):-            "Reading the V1 source failed: \(reason)"-        case .verificationFailed(let reason):-            "Verifying the migrated V2 graph failed: \(reason)"-        case .sourceChangedDuringMigration:-            "The V1 source changed while migration was running"-        case .operationFailed(let operation, let reason):-            "Migration failed while \(operation): \(reason)"-        }-    }-}--public enum V1ToV2Migrator {-    public static func migrate(-        configuration: LibraryConfiguration,-        processChecker: any MigrationRuntimeProcessChecking,-        verifier: any MigrationVerifying = LogicalMigrationVerifier(),-        capabilities: AsterismCapabilities = .m2_0-    ) async throws {-        try await migrate(-            configuration: configuration,-            processChecker: processChecker,-            verifier: verifier,-            capabilities: capabilities,-            lockAcquirer: CrossProcessMigrationLockAcquirer()-        )-    }--    static func migrate(-        configuration: LibraryConfiguration,-        processChecker: any MigrationRuntimeProcessChecking,-        verifier: any MigrationVerifying,-        capabilities: AsterismCapabilities,-        lockAcquirer: any MigrationLockAcquiring-    ) async throws {-        do {-            try processChecker.requireRuntimeProcessesClosed()-        } catch let error as V1ToV2MigrationError {-            throw error-        } catch {-            throw V1ToV2MigrationError.operationFailed(-                operation: "checking runtime processes",-                reason: String(describing: error)-            )-        }--        let fileManager = FileManager.default-        guard fileManager.fileExists(atPath: configuration.legacyStoreURL.path) else {-            throw V1ToV2MigrationError.sourceMissing(configuration.legacyStoreURL)-        }-        let destinationArtifacts = V2MigrationStore.artifactURLs(for: configuration.storeURL)-        guard !destinationArtifacts.contains(where: { fileManager.fileExists(atPath: $0.path) }),-              !fileManager.fileExists(atPath: configuration.markerURL.path) else {-            throw V1ToV2MigrationError.destinationExists(configuration.storeURL)-        }--        let lease: LockLease-        do {-            lease = try await lockAcquirer.acquireExclusive(-                at: configuration.lockURL,-                timeout: .seconds(5)-            )-        } catch {-            throw V1ToV2MigrationError.operationFailed(-                operation: "acquiring exclusive library lock",-                reason: String(describing: error)-            )-        }-        defer { withExtendedLifetime(lease) {} }--        guard !destinationArtifacts.contains(where: { fileManager.fileExists(atPath: $0.path) }),-              !fileManager.fileExists(atPath: configuration.markerURL.path) else {-            throw V1ToV2MigrationError.destinationExists(configuration.storeURL)-        }--        var ownsDestination = false-        do {-            let source = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)-            try V2LibraryValidator.validate(snapshot: source, capabilities: capabilities)--            try V2MigrationStore.create(-                snapshot: source,-                at: configuration.storeURL,-                capabilities: capabilities-            )-            ownsDestination = true-            let destination = try V2MigrationStore.readSnapshot(-                from: configuration.storeURL,-                capabilities: capabilities-            )-            try verifier.verify(source: source, destination: destination)--            let sourceAfter = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)-            guard sourceAfter == source else {-                throw V1ToV2MigrationError.sourceChangedDuringMigration-            }-            guard !fileManager.fileExists(atPath: configuration.markerURL.path) else {-                throw V1ToV2MigrationError.verificationFailed(-                    reason: "migration must not write runtime readiness"-                )-            }-        } catch {-            if ownsDestination {-                V2MigrationStore.cleanup(storeURL: configuration.storeURL)-                try? fileManager.removeItem(at: configuration.markerURL)-            }-            if let migrationError = error as? V1ToV2MigrationError {-                throw migrationError-            }-            throw V1ToV2MigrationError.operationFailed(-                operation: "copying and verifying the logical graph",-                reason: String(describing: error)-            )-        }-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swift Deleted +0 / -285
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swiftdeleted file mode 100644index c2c1e42..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2CodecTests.swift+++ /dev/null@@ -1,285 +0,0 @@-import Foundation-import Testing-@testable import AsterismCore--@Suite("Backup V2 strict codec")-struct BackupV2CodecTests {-    @Test("Current gate round-trip preserves a coherent V2 graph")-    func roundTrip() throws {-        let snapshot = try makeSnapshot(definition: segmentDefinition)-        let encoded = try BackupV2Codec.encode(-            snapshot: snapshot,-            metadata: metadata,-            capabilities: .m2_0-        )-        let decoded = try BackupV2Codec.decode(encoded, capabilities: .m2_0)--        #expect(decoded.backupFormatVersion == 2)-        #expect(decoded.databaseSchemaVersion == 2)-        #expect(decoded.capabilityGate == .m2_0)-        #expect(decoded.payload == snapshot)-    }--    @Test("Gate validation rejects unavailable phrase forms")-    func gateValidation() throws {-        let phrase = PatternDefinition.phrase(-            prefix: "Read ",-            separator: " from ",-            suffix: ".",-            order: .chapterThenWork-        )-        let snapshot = try makeSnapshot(definition: phrase)--        #expect(throws: BackupValidationError.self) {-            try BackupV2Codec.encode(-                snapshot: snapshot,-                metadata: metadata,-                capabilities: .m2_0-            )-        }--        let encoded = try BackupV2Codec.encode(-            snapshot: snapshot,-            metadata: metadata,-            capabilities: .m2_3-        )-        #expect(try BackupV2Codec.decode(encoded, capabilities: .m2_3).payload == snapshot)-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(encoded, capabilities: .m2_0)-        }-    }--    @Test("Unknown, duplicate, and missing keys are rejected")-    func malformedKeys() throws {-        let encoded = try encodeSegmentSnapshot()-        let unknown = Data(("{\"unexpected\":true," + String(decoding: encoded.dropFirst(), as: UTF8.self)).utf8)-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(unknown, capabilities: .m2_0)-        }--        let duplicate = Data(("{\"appBuild\":\"shadow\"," + String(decoding: encoded.dropFirst(), as: UTF8.self)).utf8)-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(duplicate, capabilities: .m2_0)-        }--        let missing = try mutate(encoded) { root in-            root.removeValue(forKey: "appBuild")-        }-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(missing, capabilities: .m2_0)-        }--        let missingNullable = try mutate(encoded) { root in-            var payload = root["payload"] as! [String: Any]-            var entries = payload["entries"] as! [[String: Any]]-            entries[0].removeValue(forKey: "canonicalURL")-            payload["entries"] = entries-            root["payload"] = payload-        }-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(missingNullable, capabilities: .m2_0)-        }-    }--    @Test("Unknown nested value-object keys are rejected")-    func unknownNestedKey() throws {-        let encoded = try encodeSegmentSnapshot()-        let malformed = try mutate(encoded) { root in-            var payload = root["payload"] as! [String: Any]-            var patterns = payload["titlePatterns"] as! [[String: Any]]-            var definition = patterns[0]["definition"] as! [String: Any]-            var segment = definition["segment"] as! [String: Any]-            var work = segment["work"] as! [String: Any]-            work["unexpected"] = true-            segment["work"] = work-            definition["segment"] = segment-            patterns[0]["definition"] = definition-            payload["titlePatterns"] = patterns-            root["payload"] = payload-        }--        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(malformed, capabilities: .m2_0)-        }-    }--    @Test("Invalid references are rejected after strict decoding")-    func invalidReference() throws {-        let encoded = try encodeSegmentSnapshot()-        let malformed = try mutate(encoded) { root in-            var payload = root["payload"] as! [String: Any]-            var entries = payload["entries"] as! [[String: Any]]-            entries[0]["workID"] = "99999999-9999-9999-9999-999999999999"-            payload["entries"] = entries-            root["payload"] = payload-        }--        #expect(throws: BackupValidationError.self) {-            try BackupV2Codec.decode(malformed, capabilities: .m2_0)-        }-    }--    @Test("Both tagged pattern arms are rejected")-    func bothPatternForms() throws {-        let encoded = try encodeSegmentSnapshot()-        let malformed = try mutate(encoded) { root in-            var payload = root["payload"] as! [String: Any]-            var patterns = payload["titlePatterns"] as! [[String: Any]]-            var definition = patterns[0]["definition"] as! [String: Any]-            definition["phrase"] = [-                "prefix": "Read ",-                "separator": " from ",-                "suffix": ".",-                "order": "chapterThenWork",-            ]-            patterns[0]["definition"] = definition-            payload["titlePatterns"] = patterns-            root["payload"] = payload-        }--        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(malformed, capabilities: .m2_0)-        }-    }--    @Test("Invalid chapter and assignment tuples are rejected")-    func invalidTuples() throws {-        let encoded = try encodeSegmentSnapshot()-        let malformed = try mutate(encoded) { root in-            var payload = root["payload"] as! [String: Any]-            var entries = payload["entries"] as! [[String: Any]]-            entries[0]["chapterTitle"] = NSNull()-            entries[0]["chapterTitleProvenance"] = [-                "kind": "manual",-                "patternID": NSNull(),-                "patternVersion": NSNull(),-            ]-            entries[0]["intentionallyUnattached"] = true-            payload["entries"] = entries-            root["payload"] = payload-        }--        #expect(throws: BackupValidationError.self) {-            try BackupV2Codec.decode(malformed, capabilities: .m2_0)-        }-    }--    @Test("Trailing bytes and wrong schema versions are rejected")-    func envelopeValidation() throws {-        let encoded = try encodeSegmentSnapshot()-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(encoded + Data(" trailing".utf8), capabilities: .m2_0)-        }-        let wrongSchema = try mutate(encoded) { root in-            root["databaseSchemaVersion"] = 1-        }-        #expect(throws: BackupCodecError.self) {-            try BackupV2Codec.decode(wrongSchema, capabilities: .m2_0)-        }-    }--    private var metadata: BackupMetadata {-        BackupMetadata(-            appBuild: "42",-            databaseSchemaVersion: 2,-            exportedAt: Date(timeIntervalSince1970: 1_721_000_000.123)-        )-    }--    private var segmentDefinition: PatternDefinition {-        .segment(-            work: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),-            ignored: [try! SegmentPositionSpec(origin: .end, offset: 0)]-        )-    }--    private func encodeSegmentSnapshot() throws -> Data {-        try BackupV2Codec.encode(-            snapshot: makeSnapshot(definition: segmentDefinition),-            metadata: metadata,-            capabilities: .m2_0-        )-    }--    private func makeSnapshot(definition: PatternDefinition) throws -> LibraryBackupSnapshot {-        let patternID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!-        let workID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!-        let entryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!-        let timestamp = Date(timeIntervalSince1970: 1_721_000_000.123)-        let provenance = try FieldProvenance(-            kind: .pattern,-            patternID: patternID,-            patternVersion: 1-        )-        return LibraryBackupSnapshot(-            entries: [-                EntryRecord(-                    id: entryID,-                    captureTitle: "Chapter - Work | Site",-                    captureTitleSource: .host,-                    rawURL: "https://example.com/chapter",-                    canonicalURL: nil,-                    hostname: "example.com",-                    entryIdentityKey: "https://example.com/chapter",-                    identityKeyVersion: 1,-                    chapterTitle: "Chapter",-                    chapterTitleProvenance: provenance,-                    note: "note",-                    rating: .up,-                    firstCapturedAt: timestamp,-                    lastSharedAt: timestamp,-                    modifiedAt: timestamp,-                    workID: workID,-                    workAssignmentProvenance: provenance,-                    intentionallyUnattached: false-                ),-            ],-            works: [-                WorkRecord(-                    id: workID,-                    displayTitle: "Work",-                    lastParsedTitle: "Work",-                    siteHostname: "example.com",-                    urlIdentity: nil,-                    workURL: nil,-                    genericNotes: "",-                    type: .novel,-                    genreTags: [],-                    titleProvenance: .parsed,-                    createdAt: timestamp,-                    modifiedAt: timestamp,-                    entryIDs: [entryID]-                ),-            ],-            sites: [-                SiteRecord(-                    hostname: "example.com",-                    displayName: "Example",-                    mode: .taught,-                    patternIDs: [patternID],-                    urlIdentityRule: nil,-                    junkSuffixRule: nil-                ),-            ],-            titlePatterns: [-                TitlePatternRecord(-                    id: patternID,-                    version: 1,-                    isActive: true,-                    createdAt: timestamp,-                    definition: definition,-                    siteHostname: "example.com"-                ),-            ]-        )-    }--    private func mutate(-        _ data: Data,-        mutation: (inout [String: Any]) -> Void-    ) throws -> Data {-        var root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])-        mutation(&root)-        return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2FixtureProvenanceTests.swift Deleted +0 / -143
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2FixtureProvenanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2FixtureProvenanceTests.swiftdeleted file mode 100644index a470dbc..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV2FixtureProvenanceTests.swift+++ /dev/null@@ -1,143 +0,0 @@-import Foundation-import Testing--@testable import AsterismCore--@Suite("Backup V2 fixture provenance")-struct BackupV2FixtureProvenanceTests {-  @Test("Checked M2.3 fixture exactly matches frozen legacy exporter bytes")-  func checkedFixtureMatchesFrozenExporter() throws {-    let snapshot = try makeSnapshot()-    let metadata = BackupMetadata(-      appBuild: "pre-m3-m2.3-fixture",-      databaseSchemaVersion: 2,-      exportedAt: timestamp-    )--    let exportedBytes = try LegacyBackupV2FixtureExporter.export(-      snapshot: snapshot,-      metadata: metadata-    )-    let checkedBytes = try Data(contentsOf: fixtureURL)--    #expect(exportedBytes == checkedBytes)--    // Decode via the legacy codec under m2.3 gate-    let decoded = try LegacyBackupV2Codec.decode(checkedBytes)-    #expect(decoded.backupFormatVersion == 2)-    #expect(decoded.databaseSchemaVersion == 2)-    #expect(decoded.appBuild == metadata.appBuild)-    #expect(decoded.exportedAt == metadata.exportedAt)-    #expect(decoded.capabilityGate == .m2_3)-    #expect(decoded.payload.entries.count == snapshot.entries.count)-    #expect(decoded.payload.works.count == snapshot.works.count)-    #expect(decoded.payload.sites.count == snapshot.sites.count)-    #expect(decoded.payload.titlePatterns.count == snapshot.titlePatterns.count)-  }--  private var fixtureURL: URL {-    URL(fileURLWithPath: #filePath)-      .deletingLastPathComponent()-      .appending(path: "Fixtures/backup-v2-m2.3.json")-  }--  private var timestamp: Date {-    Date(timeIntervalSince1970: 1_721_000_000.123)-  }--  private func makeSnapshot() throws -> LibraryBackupSnapshot {-    let patternID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!-    let workID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!-    let entryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!-    let provenance = try FieldProvenance(-      kind: .pattern,-      patternID: patternID,-      patternVersion: 3-    )-    let phrase = PatternDefinition.phrase(-      prefix: "Read ",-      separator: " — ",-      suffix: ".",-      order: .chapterThenWork-    )--    return LibraryBackupSnapshot(-      entries: [-        EntryRecord(-          id: entryID,-          captureTitle: "Read Chapter 7 — Constellation.",-          captureTitleSource: .host,-          rawURL: "https://example.com/read/7?story=constellation",-          canonicalURL: "https://example.com/read/7",-          hostname: "example.com",-          entryIdentityKey: "https://example.com/read/7?story=constellation",-          identityKeyVersion: 1,-          chapterTitle: "Chapter 7",-          chapterTitleProvenance: provenance,-          note: "fixture note",-          rating: .up,-          firstCapturedAt: timestamp,-          lastSharedAt: timestamp,-          modifiedAt: timestamp,-          workID: workID,-          workAssignmentProvenance: provenance,-          intentionallyUnattached: false-        )-      ],-      works: [-        WorkRecord(-          id: workID,-          displayTitle: "Constellation",-          lastParsedTitle: "Constellation",-          siteHostname: "example.com",-          urlIdentity: "constellation",-          workURL: "https://example.com/works/constellation",-          genericNotes: "fixture work",-          type: .novel,-          genreTags: ["science fiction"],-          titleProvenance: .parsed,-          createdAt: timestamp,-          modifiedAt: timestamp,-          entryIDs: [entryID]-        )-      ],-      sites: [-        SiteRecord(-          hostname: "example.com",-          displayName: "Example",-          mode: .taught,-          patternIDs: [patternID],-          urlIdentityRule: try URLIdentityRule(-            version: 4,-            component: .queryItem,-            queryName: "story"-          ),-          junkSuffixRule: nil-        ),-        SiteRecord(-          hostname: "articles.example",-          displayName: "Articles Example",-          mode: .articles,-          patternIDs: [],-          urlIdentityRule: try URLIdentityRule(-            version: 2,-            component: .pathSegment,-            origin: .end,-            offset: 0-          ),-          junkSuffixRule: nil-        ),-      ],-      titlePatterns: [-        TitlePatternRecord(-          id: patternID,-          version: 3,-          isActive: true,-          createdAt: timestamp,-          definition: phrase,-          siteHostname: "example.com"-        )-      ]-    )-  }-}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV3CodecTests.swift Deleted +0 / -399
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV3CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV3CodecTests.swiftdeleted file mode 100644index e21adc2..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV3CodecTests.swift+++ /dev/null@@ -1,399 +0,0 @@-import Foundation-import Testing--@testable import AsterismCore--// MARK: - Backup V3 Codec Tests--@Suite("Backup V3 codec")-struct BackupV3CodecTests {--    // MARK: - Round Trip--    @Test("V3 encode/decode round-trip preserves a minimal payload")-    func roundTrip() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "test-42", exportedAt: timestamp)--        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)-        let decoded = try BackupV3Codec.decode(encoded)--        #expect(decoded.backupFormatVersion == 3)-        #expect(decoded.databaseSchemaVersion == 3)-        // The frozen 3/3 format declares the literal "m3" gate, independent of-        // AsterismCapabilities.current (now .m4) — a frozen format is never-        // redefined in place (Decision 2).-        #expect(decoded.capabilityGate == "m3")-        #expect(decoded.entryCount == payload.entries.count)-        #expect(decoded.workCount == payload.works.count)-        #expect(decoded.payload == payload)-    }--    @Test("V3 round-trip with URL rules and identity fields")-    func roundTripWithURLFields() throws {-        let payload = makePayloadWithURLRules()-        let metadata = BackupV3Metadata(appBuild: "test-v3", exportedAt: timestamp)--        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)-        let decoded = try BackupV3Codec.decode(encoded)--        #expect(decoded.payload.urlRules.count == 1)-        #expect(decoded.payload.entries[0].identityBasis == .urlRule)-        #expect(decoded.payload.entries[0].urlWorkIdentity == "42")-        #expect(decoded.payload.entries[0].chapterSequence == "7")-        #expect(decoded.payload.works[0].urlIdentityState == .rule)-    }--    // MARK: - Checksum Validation--    @Test("V3 decode rejects tampered payload (checksum mismatch)")-    func rejectsTamperedPayload() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "tamper", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        // Tamper: change note in the JSON-        var json = try #require(String(data: encoded, encoding: .utf8))-        json = json.replacingOccurrences(of: "\"note\":\"\"", with: "\"note\":\"hacked\"")-        let tampered = Data(json.utf8)--        #expect(throws: BackupV3CodecError.self) {-            try BackupV3Codec.decode(tampered)-        }-    }--    // MARK: - Count Validation--    @Test("V3 decode rejects incorrect entry count")-    func rejectsWrongEntryCount() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "count", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root["entryCount"] = 999-        }-        #expect(throws: BackupV3CodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    @Test("V3 decode rejects incorrect work count")-    func rejectsWrongWorkCount() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "count", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root["workCount"] = 999-        }-        #expect(throws: BackupV3CodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    // MARK: - Envelope Validation--    @Test("V3 decode rejects wrong format version")-    func rejectsWrongFormat() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "fmt", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root["backupFormatVersion"] = 2-        }-        #expect(throws: BackupV3CodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    @Test("V3 decode rejects wrong schema version")-    func rejectsWrongSchema() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "schema", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root["databaseSchemaVersion"] = 2-        }-        #expect(throws: BackupV3CodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    @Test("V3 decode rejects unknown root key")-    func rejectsUnknownKey() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "unk", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root["unknownField"] = "surprise"-        }-        #expect(throws: BackupCodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    @Test("V3 decode rejects missing checksum key")-    func rejectsMissingChecksum() throws {-        let payload = makeMinimalPayload()-        let metadata = BackupV3Metadata(appBuild: "miss", exportedAt: timestamp)-        let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)--        let data = try mutate(encoded) { root in-            root.removeValue(forKey: "checksum")-        }-        #expect(throws: BackupCodecError.self) {-            try BackupV3Codec.decode(data)-        }-    }--    // MARK: - Reference Validation--    @Test("V3 decode rejects unresolved Work→Site reference")-    func rejectsUnresolvedWorkSite() throws {-        var payload = makeMinimalPayload()-        // Change work's hostname to non-existent site-        let badWork = BackupV3Work(-            id: payload.works[0].id,-            displayTitle: "Bad",-            lastParsedTitle: nil,-            siteHostname: "nonexistent.test",-            urlIdentity: nil,-            urlIdentityState: .none,-            urlIdentityRuleID: nil,-            urlIdentityRuleVersion: nil,-            workURL: nil,-            genericNotes: "",-            type: .other,-            genreTags: [],-            titleProvenance: .parsed,-            createdAt: timestamp,-            modifiedAt: timestamp,-            entryIDs: []-        )-        payload = BackupV3Payload(-            entries: payload.entries,-            works: [badWork],-            sites: payload.sites,-            titlePatterns: payload.titlePatterns,-            urlRules: payload.urlRules-        )-        let metadata = BackupV3Metadata(appBuild: "ref", exportedAt: timestamp)-        #expect(throws: (any Error).self) {-            // encode will succeed but decode will fail reference validation-            let encoded = try BackupV3Codec.encode(payload: payload, metadata: metadata)-            try BackupV3Codec.decode(encoded)-        }-    }--    // MARK: - Helpers--    private var timestamp: Date { Date(timeIntervalSince1970: 1_721_000_000.123) }--    private func makeMinimalPayload() -> BackupV3Payload {-        let entryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!-        let workID = UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!--        return BackupV3Payload(-            entries: [-                BackupV3Entry(-                    id: entryID,-                    captureTitle: "Test",-                    captureTitleSource: .manual,-                    rawURL: "https://example.com/test",-                    canonicalURL: nil,-                    hostname: "example.com",-                    entryIdentityKey: "https://example.com/test",-                    identityKeyVersion: 1,-                    identityBasis: .conservative,-                    identityURLRuleID: nil,-                    identityURLRuleVersion: nil,-                    urlWorkIdentity: nil,-                    urlWorkRuleID: nil,-                    urlWorkRuleVersion: nil,-                    chapterSequence: nil,-                    chapterSequenceRuleID: nil,-                    chapterSequenceRuleVersion: nil,-                    chapterTitle: nil,-                    chapterTitleProvenance: try! FieldProvenance(kind: .none),-                    note: "",-                    rating: nil,-                    firstCapturedAt: timestamp,-                    lastSharedAt: timestamp,-                    modifiedAt: timestamp,-                    workID: workID,-                    workAssignmentProvenance: try! FieldProvenance(kind: .none),-                    workURLRuleID: nil,-                    workURLRuleVersion: nil,-                    workURLAssignmentKind: nil,-                    workPatternID: nil,-                    workPatternVersion: nil,-                    intentionallyUnattached: false-                ),-            ],-            works: [-                BackupV3Work(-                    id: workID,-                    displayTitle: "Test Work",-                    lastParsedTitle: nil,-                    siteHostname: "example.com",-                    urlIdentity: nil,-                    urlIdentityState: .none,-                    urlIdentityRuleID: nil,-                    urlIdentityRuleVersion: nil,-                    workURL: nil,-                    genericNotes: "",-                    type: .other,-                    genreTags: [],-                    titleProvenance: .manual,-                    createdAt: timestamp,-                    modifiedAt: timestamp,-                    entryIDs: [entryID]-                ),-            ],-            sites: [-                BackupV3Site(-                    hostname: "example.com",-                    displayName: "Example",-                    mode: .untaught,-                    titleInterpretation: nil,-                    patternIDs: [],-                    urlRuleIDs: [],-                    junkSuffixRule: nil-                ),-            ],-            titlePatterns: [],-            urlRules: []-        )-    }--    private func makePayloadWithURLRules() -> BackupV3Payload {-        let entryID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!-        let workID = UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!-        let ruleID = UUID(uuidString: "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC")!-        let patternID = UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!--        let key = "v2|h11:example.com|w2:42|s1:7"--        return BackupV3Payload(-            entries: [-                BackupV3Entry(-                    id: entryID,-                    captureTitle: "Chapter 7",-                    captureTitleSource: .host,-                    rawURL: "https://example.com/series/42/chapter/7",-                    canonicalURL: nil,-                    hostname: "example.com",-                    entryIdentityKey: key,-                    identityKeyVersion: 2,-                    identityBasis: .urlRule,-                    identityURLRuleID: ruleID,-                    identityURLRuleVersion: 1,-                    urlWorkIdentity: "42",-                    urlWorkRuleID: ruleID,-                    urlWorkRuleVersion: 1,-                    chapterSequence: "7",-                    chapterSequenceRuleID: ruleID,-                    chapterSequenceRuleVersion: 1,-                    chapterTitle: "Chapter 7",-                    chapterTitleProvenance: try! FieldProvenance(-                        kind: .pattern, patternID: patternID, patternVersion: 1-                    ),-                    note: "",-                    rating: nil,-                    firstCapturedAt: timestamp,-                    lastSharedAt: timestamp,-                    modifiedAt: timestamp,-                    workID: workID,-                    workAssignmentProvenance: try! FieldProvenance(kind: .urlRule),-                    workURLRuleID: ruleID,-                    workURLRuleVersion: 1,-                    workURLAssignmentKind: .identity,-                    workPatternID: nil,-                    workPatternVersion: nil,-                    intentionallyUnattached: false-                ),-            ],-            works: [-                BackupV3Work(-                    id: workID,-                    displayTitle: "Series 42",-                    lastParsedTitle: "Series 42",-                    siteHostname: "example.com",-                    urlIdentity: "42",-                    urlIdentityState: .rule,-                    urlIdentityRuleID: ruleID,-                    urlIdentityRuleVersion: 1,-                    workURL: nil,-                    genericNotes: "",-                    type: .other,-                    genreTags: [],-                    titleProvenance: .parsed,-                    createdAt: timestamp,-                    modifiedAt: timestamp,-                    entryIDs: [entryID]-                ),-            ],-            sites: [-                BackupV3Site(-                    hostname: "example.com",-                    displayName: "Example",-                    mode: .taught,-                    titleInterpretation: .pattern,-                    patternIDs: [patternID],-                    urlRuleIDs: [ruleID],-                    junkSuffixRule: nil-                ),-            ],-            titlePatterns: [-                BackupV3TitlePattern(-                    id: patternID,-                    version: 1,-                    isActive: true,-                    createdAt: timestamp,-                    definition: .segment(-                        work: try! SegmentRangeSpec(origin: .end, offset: 1, length: 1),-                        ignored: [try! SegmentPositionSpec(origin: .end, offset: 0)]-                    ),-                    siteHostname: "example.com"-                ),-            ],-            urlRules: [-                BackupV3URLRule(-                    id: ruleID,-                    version: 1,-                    isCurrent: true,-                    createdAt: timestamp,-                    origin: .readerTaught,-                    definition: .workAndSequence(-                        work: URLFieldSelector(-                            locator: .pathBracketed(-                                left: .literal(ExactScalarString("series")),-                                right: .literal(ExactScalarString("chapter"))-                            )-                        ),-                        sequence: URLFieldSelector(-                            locator: .pathBracketed(-                                left: .literal(ExactScalarString("chapter")),-                                right: .end-                            )-                        )-                    ),-                    siteHostname: "example.com"-                ),-            ]-        )-    }--    private func mutate(-        _ data: Data,-        mutation: (inout [String: Any]) -> Void-    ) throws -> Data {-        var root = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])-        mutation(&root)-        return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json Deleted +0 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.jsondeleted file mode 100644index cef1694..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/empty-library.json+++ /dev/null@@ -1 +0,0 @@-{"header":{"appBuild":"1","backupFormatVersion":1,"checksum":"9e81a28e8b352eccadedd5d6d77c7dace28189cfb1b5951fa41397415aa4eebd","databaseSchemaVersion":1,"entryCount":0,"exportedAt":"2026-07-17T00:00:00.000Z","workCount":0},"payload":{"entries":[],"sites":[],"titlePatterns":[],"works":[]}}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json Deleted +0 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.jsondeleted file mode 100644index 7733a8d..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/BackupV1/non-empty-library.json+++ /dev/null@@ -1 +0,0 @@-{"header":{"appBuild":"42","backupFormatVersion":1,"checksum":"00d460b4b0d127ca67dc4e07903ca3795e6fa21dec74450303f54ccc0d9ae7ae","databaseSchemaVersion":1,"entryCount":2,"exportedAt":"2026-07-17T12:00:00.000Z","workCount":1},"payload":{"entries":[{"canonicalURL":"https://example.com/chapter-1","captureTitle":"Chapter 1 — 日本語テスト","captureTitleSource":"safariDocument","chapterTitle":"Chapter 1","chapterTitleProvenance":{"kind":"pattern","patternID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","patternVersion":2},"entryIdentityKey":"https://example.com/manga/123/1","firstCapturedAt":"2026-07-01T10:00:00.000Z","hostname":"example.com","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2026-07-01T10:00:00.000Z","modifiedAt":"2026-07-02T15:30:00.500Z","note":"Great chapter with émojis 🎉 and\nnewlines","rating":"up","rawURL":"https://example.com/manga/123/1?utm_source=twitter","workAssignmentProvenance":{"kind":"manual","patternID":null,"patternVersion":null},"workID":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"},{"canonicalURL":null,"captureTitle":"Untitled Page","captureTitleSource":"manual","chapterTitle":null,"chapterTitleProvenance":{"kind":"none","patternID":null,"patternVersion":null},"entryIdentityKey":"https://other.test/article","firstCapturedAt":"2026-07-03T08:15:00.123Z","hostname":"other.test","id":"22222222-2222-2222-2222-222222222222","identityKeyVersion":1,"intentionallyUnattached":true,"lastSharedAt":"2026-07-03T08:15:00.123Z","modifiedAt":"2026-07-03T08:15:00.123Z","note":"","rating":null,"rawURL":"https://other.test/article","workAssignmentProvenance":{"kind":"manual","patternID":null,"patternVersion":null},"workID":null}],"sites":[{"displayName":"example.com","hostname":"example.com","junkSuffixRule":{"anchors":[{"offset":1,"origin":"end"}],"version":1},"mode":"taught","patternIDs":["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","cccccccc-cccc-cccc-cccc-cccccccccccc"],"urlIdentityRule":{"component":"pathSegment","offset":1,"origin":"start","queryName":null,"version":1}},{"displayName":"other.test","hostname":"other.test","junkSuffixRule":null,"mode":"untaught","patternIDs":[],"urlIdentityRule":null}],"titlePatterns":[{"createdAt":"2026-06-15T12:00:00.000Z","id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","isActive":true,"junkAnchors":[{"offset":0,"origin":"end"},{"offset":2,"origin":"start"}],"siteHostname":"example.com","version":2,"workAnchor":{"length":2,"offset":0,"origin":"start"}},{"createdAt":"2026-06-10T08:00:00.000Z","id":"cccccccc-cccc-cccc-cccc-cccccccccccc","isActive":false,"junkAnchors":[],"siteHostname":"example.com","version":1,"workAnchor":{"length":1,"offset":1,"origin":"end"}}],"works":[{"createdAt":"2026-07-01T09:00:00.000Z","displayTitle":"My Manga Series","entryIDs":["11111111-1111-1111-1111-111111111111"],"genericNotes":"Notes about the series","genreTags":["fantasy","action"],"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","lastParsedTitle":"My Manga","modifiedAt":"2026-07-02T15:30:00.500Z","siteHostname":"example.com","titleProvenance":"manual","type":"toon","urlIdentity":null,"workURL":"https://example.com/manga/123"}]}}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-v2-m2.3.json Deleted +0 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-v2-m2.3.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-v2-m2.3.jsondeleted file mode 100644index b633fe4..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-v2-m2.3.json+++ /dev/null@@ -1 +0,0 @@-{"appBuild":"pre-m3-m2.3-fixture","backupFormatVersion":2,"capabilityGate":"m2.3","databaseSchemaVersion":2,"exportedAt":"2024-07-14T23:33:20.123Z","payload":{"entries":[{"canonicalURL":"https://example.com/read/7","captureTitle":"Read Chapter 7 — Constellation.","captureTitleSource":"host","chapterTitle":"Chapter 7","chapterTitleProvenance":{"kind":"pattern","patternID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","patternVersion":3},"entryIdentityKey":"https://example.com/read/7?story=constellation","firstCapturedAt":"2024-07-14T23:33:20.123Z","hostname":"example.com","id":"11111111-1111-1111-1111-111111111111","identityKeyVersion":1,"intentionallyUnattached":false,"lastSharedAt":"2024-07-14T23:33:20.123Z","modifiedAt":"2024-07-14T23:33:20.123Z","note":"fixture note","rating":"up","rawURL":"https://example.com/read/7?story=constellation","workAssignmentProvenance":{"kind":"pattern","patternID":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","patternVersion":3},"workID":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}],"sites":[{"displayName":"Example","hostname":"example.com","junkSuffixRule":null,"mode":"taught","patternIDs":["bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"],"urlIdentityRule":{"component":"queryItem","offset":null,"origin":null,"queryName":"story","version":4}},{"displayName":"Articles Example","hostname":"articles.example","junkSuffixRule":null,"mode":"articles","patternIDs":[],"urlIdentityRule":{"component":"pathSegment","offset":0,"origin":"end","queryName":null,"version":2}}],"titlePatterns":[{"createdAt":"2024-07-14T23:33:20.123Z","definition":{"phrase":{"order":"chapterThenWork","prefix":"Read ","separator":" — ","suffix":"."}},"id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","isActive":true,"siteHostname":"example.com","version":3}],"works":[{"createdAt":"2024-07-14T23:33:20.123Z","displayTitle":"Constellation","entryIDs":["11111111-1111-1111-1111-111111111111"],"genericNotes":"fixture work","genreTags":["science fiction"],"id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","lastParsedTitle":"Constellation","modifiedAt":"2024-07-14T23:33:20.123Z","siteHostname":"example.com","titleProvenance":"parsed","type":"novel","urlIdentity":"constellation","workURL":"https://example.com/works/constellation"}]}}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2CodecTests.swift Deleted +0 / -327
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2CodecTests.swiftdeleted file mode 100644index 3f1ec2b..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2CodecTests.swift+++ /dev/null@@ -1,327 +0,0 @@-import Foundation-import Testing--@testable import AsterismCore--// MARK: - Legacy V2 Codec Tests--@Suite("Legacy Backup V2 codec and mapping")-struct LegacyBackupV2CodecTests {--    // MARK: - Frozen Fixture Decode--    @Test("Legacy codec decodes the checked-in m2.3 fixture exactly")-    func decodesCheckedFixture() throws {-        let data = try Data(contentsOf: fixtureURL)-        let document = try LegacyBackupV2Codec.decode(data)--        #expect(document.backupFormatVersion == 2)-        #expect(document.databaseSchemaVersion == 2)-        #expect(document.capabilityGate == .m2_3)-        #expect(document.payload.entries.count == 1)-        #expect(document.payload.works.count == 1)-        #expect(document.payload.sites.count == 2)-        #expect(document.payload.titlePatterns.count == 1)-    }--    @Test("Legacy codec preserves query-name dormant URL rule")-    func preservesQueryRule() throws {-        let data = try Data(contentsOf: fixtureURL)-        let document = try LegacyBackupV2Codec.decode(data)--        let taughtSite = document.payload.sites.first { $0.hostname == "example.com" }!-        let rule = try #require(taughtSite.urlIdentityRule)-        #expect(rule.component == .queryItem)-        #expect(rule.queryName == "story")-        #expect(rule.version == 4)-        #expect(rule.origin == nil)-        #expect(rule.offset == nil)-    }--    @Test("Legacy codec preserves positional path dormant URL rule")-    func preservesPositionalRule() throws {-        let data = try Data(contentsOf: fixtureURL)-        let document = try LegacyBackupV2Codec.decode(data)--        let articlesSite = document.payload.sites.first { $0.hostname == "articles.example" }!-        let rule = try #require(articlesSite.urlIdentityRule)-        #expect(rule.component == .pathSegment)-        #expect(rule.origin == .end)-        #expect(rule.offset == 0)-        #expect(rule.queryName == nil)-        #expect(rule.version == 2)-    }--    @Test("Legacy codec preserves all Work identity and URL fields")-    func preservesWorkIdentityFields() throws {-        let data = try Data(contentsOf: fixtureURL)-        let document = try LegacyBackupV2Codec.decode(data)--        let work = document.payload.works[0]-        #expect(work.urlIdentity == "constellation")-        #expect(work.workURL == "https://example.com/works/constellation")-    }--    // MARK: - Gate Rejection--    @Test("Legacy codec rejects m2.0 gate")-    func rejectsM2_0Gate() throws {-        let data = try mutateFixture { root in-            root["capabilityGate"] = "m2.0"-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects m2.1 gate")-    func rejectsM2_1Gate() throws {-        let data = try mutateFixture { root in-            root["capabilityGate"] = "m2.1"-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects m2.2 gate")-    func rejectsM2_2Gate() throws {-        let data = try mutateFixture { root in-            root["capabilityGate"] = "m2.2"-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects m3 gate")-    func rejectsM3Gate() throws {-        let data = try mutateFixture { root in-            root["capabilityGate"] = "m3"-        }-        #expect(throws: (any Error).self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects future gate value")-    func rejectsFutureGate() throws {-        let data = try mutateFixture { root in-            root["capabilityGate"] = "m4.0"-        }-        #expect(throws: (any Error).self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    // MARK: - Envelope Validation--    @Test("Legacy codec rejects wrong format version")-    func rejectsWrongFormat() throws {-        let data = try mutateFixture { root in-            root["backupFormatVersion"] = 3-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects wrong schema version")-    func rejectsWrongSchema() throws {-        let data = try mutateFixture { root in-            root["databaseSchemaVersion"] = 3-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects unknown root key (V3 field inserted into V2)")-    func rejectsUnknownRootKey() throws {-        let data = try mutateFixture { root in-            root["checksum"] = "abc123"-        }-        #expect(throws: BackupCodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects entryCount key in V2 envelope")-    func rejectsEntryCountKey() throws {-        let data = try mutateFixture { root in-            root["entryCount"] = 1-        }-        #expect(throws: BackupCodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Legacy codec rejects duplicate key")-    func rejectsDuplicateKey() throws {-        let raw = try Data(contentsOf: fixtureURL)-        let json = String(decoding: raw, as: UTF8.self)-        // Insert a duplicate key at the root-        let duplicate = Data(("{\"appBuild\":\"shadow\"," + json.dropFirst()).utf8)-        #expect(throws: BackupCodecError.self) {-            try LegacyBackupV2Codec.decode(duplicate)-        }-    }--    @Test("Legacy codec rejects missing required key")-    func rejectsMissingKey() throws {-        let data = try mutateFixture { root in-            root.removeValue(forKey: "appBuild")-        }-        #expect(throws: BackupCodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    // MARK: - Dormant URL Field Validation (Requirement 1.16)--    @Test("Rejects dormant path rule with negative offset")-    func rejectsNegativeOffset() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var sites = payload["sites"] as! [[String: Any]]-            sites[1]["urlIdentityRule"] = [-                "version": 2, "component": "pathSegment",-                "origin": "end", "offset": -1, "queryName": NSNull(),-            ] as [String: Any]-            payload["sites"] = sites-            root["payload"] = payload-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Rejects dormant path rule with zero version")-    func rejectsZeroVersion() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var sites = payload["sites"] as! [[String: Any]]-            sites[1]["urlIdentityRule"] = [-                "version": 0, "component": "pathSegment",-                "origin": "end", "offset": 0, "queryName": NSNull(),-            ] as [String: Any]-            payload["sites"] = sites-            root["payload"] = payload-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Rejects dormant query rule with blank name")-    func rejectsBlankQueryName() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var sites = payload["sites"] as! [[String: Any]]-            sites[0]["urlIdentityRule"] = [-                "version": 4, "component": "queryItem",-                "origin": NSNull(), "offset": NSNull(), "queryName": "   ",-            ] as [String: Any]-            payload["sites"] = sites-            root["payload"] = payload-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Rejects Work with blank URL identity")-    func rejectsBlankWorkIdentity() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var works = payload["works"] as! [[String: Any]]-            works[0]["urlIdentity"] = "   "-            payload["works"] = works-            root["payload"] = payload-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Rejects Work with invalid Work URL")-    func rejectsInvalidWorkURL() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var works = payload["works"] as! [[String: Any]]-            works[0]["workURL"] = "not-a-url"-            payload["works"] = works-            root["payload"] = payload-        }-        #expect(throws: LegacyBackupV2CodecError.self) {-            try LegacyBackupV2Codec.decode(data)-        }-    }--    @Test("Accepts absent dormant URL rule")-    func acceptsAbsentRule() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var sites = payload["sites"] as! [[String: Any]]-            sites[0]["urlIdentityRule"] = NSNull()-            sites[1]["urlIdentityRule"] = NSNull()-            payload["sites"] = sites-            root["payload"] = payload-        }-        let document = try LegacyBackupV2Codec.decode(data)-        #expect(document.payload.sites[0].urlIdentityRule == nil)-    }--    @Test("Accepts absent Work URL identity and Work URL")-    func acceptsAbsentWorkFields() throws {-        let data = try mutateFixture { root in-            var payload = root["payload"] as! [String: Any]-            var works = payload["works"] as! [[String: Any]]-            works[0]["urlIdentity"] = NSNull()-            works[0]["workURL"] = NSNull()-            payload["works"] = works-            root["payload"] = payload-        }-        let document = try LegacyBackupV2Codec.decode(data)-        #expect(document.payload.works[0].urlIdentity == nil)-        #expect(document.payload.works[0].workURL == nil)-    }--    @Test("V2 import maps taught Sites to pattern interpretation")-    func mapsTaughtSiteInterpretation() throws {-        let data = try Data(contentsOf: fixtureURL)-        let document = try LegacyBackupV2Codec.decode(data)-        let payload = try V2ToV3BackupMapper.map(document.payload)--        let taughtSite = try #require(payload.sites.first { $0.mode == .taught })-        #expect(taughtSite.titleInterpretation == .pattern)--        let articlesSite = try #require(payload.sites.first { $0.mode == .articles })-        #expect(articlesSite.titleInterpretation == nil)--        // The runtime import path is V4 (Decision 2): 2/2 chains V2→V3→V4. The V4-        // payload drops the Site interpretation column, so the taught/articles-        // distinction is carried by the site modes and mapped title patterns.-        let plan = try BackupImporter.planV4(from: data)-        #expect(plan.counts.sites == payload.sites.count)-        #expect(plan.payload.sites.contains { $0.mode == .taught })-        #expect(plan.payload.sites.contains { $0.mode == .articles })-    }--    // MARK: - Helpers--    private var fixtureURL: URL {-        URL(fileURLWithPath: #filePath)-            .deletingLastPathComponent()-            .appending(path: "Fixtures/backup-v2-m2.3.json")-    }--    private func mutateFixture(-        mutation: (inout [String: Any]) -> Void-    ) throws -> Data {-        let raw = try Data(contentsOf: fixtureURL)-        var root = try #require(JSONSerialization.jsonObject(with: raw) as? [String: Any])-        mutation(&root)-        return try JSONSerialization.data(withJSONObject: root, options: [.sortedKeys])-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2FixtureExporter.swift Deleted +0 / -185
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2FixtureExporter.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2FixtureExporter.swiftdeleted file mode 100644index 2c9938e..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LegacyBackupV2FixtureExporter.swift+++ /dev/null@@ -1,185 +0,0 @@-import Foundation-@testable import AsterismCore--/// Test-only mechanical freeze of the pre-M3 `BackupExporter` V2 encoding path.-///-/// This exporter exists exclusively in the test target. No application or library-/// product links or exposes it. It produces the exact six-key Backup V2 envelope-/// from a `LibraryBackupSnapshot` so that `BackupV2FixtureProvenanceTests` can-/// compare its bytes byte-for-byte with the checked-in fixture.-///-/// The encoding is identical to the pre-M3 `BackupV2Codec.encode` path using-/// `BackupV2JSONWriter` and `BackupV2DateFormatter`. It uses the frozen M2.3-/// capability gate.-enum LegacyBackupV2FixtureExporter {-    /// Encodes a legacy V2 backup document from a snapshot and metadata.-    ///-    /// - Parameters:-    ///   - snapshot: The complete library backup snapshot to encode.-    ///   - metadata: Backup metadata (app build, schema version, export date).-    /// - Returns: JSON data in the exact frozen V2 format.-    static func export(-        snapshot: LibraryBackupSnapshot,-        metadata: BackupMetadata-    ) throws -> Data {-        guard metadata.databaseSchemaVersion == 2 else {-            throw BackupCodecError.invalidSchemaVersion(metadata.databaseSchemaVersion)-        }-        let capabilities = AsterismCapabilities.m2_3-        try V2LibraryValidator.validate(snapshot: snapshot, capabilities: capabilities)-        return try JSONSerialization.data(-            withJSONObject: document(snapshot: snapshot, metadata: metadata, capabilities: capabilities),-            options: [.sortedKeys, .withoutEscapingSlashes]-        )-    }--    // MARK: - Document builder (frozen from pre-M3 BackupV2JSONWriter)--    private static func document(-        snapshot: LibraryBackupSnapshot,-        metadata: BackupMetadata,-        capabilities: AsterismCapabilities-    ) -> [String: Any] {-        [-            "backupFormatVersion": 2,-            "databaseSchemaVersion": metadata.databaseSchemaVersion,-            "appBuild": metadata.appBuild,-            "exportedAt": dateString(from: metadata.exportedAt),-            "capabilityGate": capabilities.gate.rawValue,-            "payload": [-                "entries": snapshot.entries.map(entry),-                "works": snapshot.works.map(work),-                "sites": snapshot.sites.map(site),-                "titlePatterns": snapshot.titlePatterns.map(pattern),-            ],-        ]-    }--    private static func entry(_ value: EntryRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "captureTitle": value.captureTitle,-            "captureTitleSource": value.captureTitleSource.rawValue,-            "rawURL": value.rawURL,-            "canonicalURL": nullable(value.canonicalURL),-            "hostname": value.hostname,-            "entryIdentityKey": value.entryIdentityKey,-            "identityKeyVersion": value.identityKeyVersion,-            "chapterTitle": nullable(value.chapterTitle),-            "chapterTitleProvenance": provenance(value.chapterTitleProvenance),-            "note": value.note,-            "rating": nullable(value.rating?.rawValue),-            "firstCapturedAt": dateString(from: value.firstCapturedAt),-            "lastSharedAt": dateString(from: value.lastSharedAt),-            "modifiedAt": dateString(from: value.modifiedAt),-            "workID": nullable(value.workID.map(uuid)),-            "workAssignmentProvenance": provenance(value.workAssignmentProvenance),-            "intentionallyUnattached": value.intentionallyUnattached,-        ]-    }--    private static func work(_ value: WorkRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "displayTitle": value.displayTitle,-            "lastParsedTitle": nullable(value.lastParsedTitle),-            "siteHostname": value.siteHostname,-            "urlIdentity": nullable(value.urlIdentity),-            "workURL": nullable(value.workURL),-            "genericNotes": value.genericNotes,-            "type": value.type.rawValue,-            "genreTags": value.genreTags,-            "titleProvenance": value.titleProvenance.rawValue,-            "createdAt": dateString(from: value.createdAt),-            "modifiedAt": dateString(from: value.modifiedAt),-            "entryIDs": value.entryIDs.map(uuid),-        ]-    }--    private static func site(_ value: SiteRecord) -> [String: Any] {-        [-            "hostname": value.hostname,-            "displayName": value.displayName,-            "mode": value.mode.rawValue,-            "patternIDs": value.patternIDs.map(uuid),-            "urlIdentityRule": nullable(value.urlIdentityRule.map(urlIdentityRule)),-            "junkSuffixRule": nullable(value.junkSuffixRule.map(junkSuffixRule)),-        ]-    }--    private static func pattern(_ value: TitlePatternRecord) -> [String: Any] {-        [-            "id": uuid(value.id),-            "version": value.version,-            "isActive": value.isActive,-            "createdAt": dateString(from: value.createdAt),-            "definition": definition(value.definition),-            "siteHostname": value.siteHostname,-        ]-    }--    private static func definition(_ value: PatternDefinition) -> [String: Any] {-        switch value {-        case .segment(let work, let ignored):-            [-                "segment": [-                    "work": range(work),-                    "ignored": ignored.map(position),-                ],-            ]-        case .phrase(let prefix, let separator, let suffix, let order):-            [-                "phrase": [-                    "prefix": prefix,-                    "separator": separator,-                    "suffix": suffix,-                    "order": order.rawValue,-                ],-            ]-        case .chapterlessSegment, .chapterlessPhrase, .wholeTitle:-            preconditionFailure("V2 fixtures cannot encode M4 title-rule forms")-        }-    }--    private static func range(_ value: SegmentRangeSpec) -> [String: Any] {-        ["origin": value.origin.rawValue, "offset": value.offset, "length": value.length]-    }--    private static func position(_ value: SegmentPositionSpec) -> [String: Any] {-        ["origin": value.origin.rawValue, "offset": value.offset]-    }--    private static func provenance(_ value: FieldProvenance) -> [String: Any] {-        [-            "kind": value.kind.rawValue,-            "patternID": nullable(value.patternID.map(uuid)),-            "patternVersion": nullable(value.patternVersion),-        ]-    }--    private static func urlIdentityRule(_ value: URLIdentityRule) -> [String: Any] {-        [-            "version": value.version,-            "component": value.component.rawValue,-            "origin": nullable(value.origin?.rawValue),-            "offset": nullable(value.offset),-            "queryName": nullable(value.queryName),-        ]-    }--    private static func junkSuffixRule(_ value: JunkSuffixRule) -> [String: Any] {-        ["version": value.version, "anchors": value.anchors.map(position)]-    }--    private static func uuid(_ value: UUID) -> String {-        value.uuidString.lowercased()-    }--    private static func nullable(_ value: Any?) -> Any {-        value ?? NSNull()-    }--    private static func dateString(from date: Date) -> String {-        LegacyV2DateFormatter.string(from: date)-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift Deleted +0 / -288
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swiftdeleted file mode 100644index 8f7048e..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M2ScaleHarnessTests.swift+++ /dev/null@@ -1,288 +0,0 @@-import Foundation-import Testing-@testable import AsterismCore--@Suite("M2 supported-scale harness", .serialized)-struct M2ScaleHarnessTests {-    @Test("Fixture is deterministic and matches the required 20,000/2,000 shape")-    func fixtureShape() throws {-        let fixture = try M2ScaleFixture.make()--        #expect(fixture.entries.count == 20_000)-        #expect(fixture.works.count == 2_000)-        #expect(fixture.siteEntryCounts.values.reduce(0, +) == 20_000)-        #expect(fixture.siteEntryCounts.values.count(where: { $0 == 5_000 }) == 1)-        #expect(fixture.siteEntryCounts[fixture.targetHostname] == 5_000)-        #expect(fixture.siteWorkCounts.values.reduce(0, +) == 2_000)-        #expect(fixture.segmentEdits.count == 10)-        #expect(fixture.phraseEdits.count == 10)-        #expect(fixture.entries.contains(where: { $0.chapterTitleProvenance.kind == .manual }))-        #expect(fixture.entries.contains(where: { $0.chapterTitleProvenance.kind == .pattern }))-        #expect(fixture.entries.contains(where: { $0.workAssignmentProvenance.kind == .manual }))-        #expect(fixture.entries.contains(where: { $0.workAssignmentProvenance.kind == .pattern }))-        #expect(fixture.entries.contains(where: M2ScaleFixture.isActionable))--        let repeated = try M2ScaleFixture.make()-        #expect(fixture.entries.first?.id == repeated.entries.first?.id)-        #expect(fixture.entries.last?.id == repeated.entries.last?.id)-        #expect(fixture.works.first?.id == repeated.works.first?.id)-        #expect(fixture.works.last?.id == repeated.works.last?.id)-    }--    @Test("Repository seeder persists one valid exact-scale graph")-    func repositorySeeder() async throws {-        let root = FileManager.default.temporaryDirectory-            .appending(path: "asterism-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory)-        defer { try? FileManager.default.removeItem(at: root) }-        let configuration = LibraryConfiguration(rootDirectory: root)-        let repository = try await LibraryRepository.openForApp(-            configuration,-            capabilities: .m2_3-        )--        try await repository.seedM2PerformanceFixture()--        let counts = try await repository.debugCounts()-        #expect(counts.entries == 20_000)-        #expect(counts.works == 2_000)-        #expect(counts.sites == 6)-        #expect(counts.titlePatterns == 2)--        let snapshot = try await repository.backupSnapshot()-        try V2LibraryValidator.validate(snapshot: snapshot, capabilities: .m2_3)-        #expect(snapshot.entries.count(where: { $0.hostname == "scale.test" }) == 5_000)-        #expect(snapshot.sites.count(where: { $0.mode == .taught }) == 1)-        #expect(snapshot.sites.count(where: { $0.mode == .articles }) == 1)-    }--    /// The app builds its repository with `AsterismCapabilities.current`, so a-    /// seeder that only accepts a historical gate cannot seed the scenario the-    /// UI suite launches. That is exactly what a `capabilities == .m2_3` guard-    /// did across the .m3 and .m4 bumps. Pinning this test to `.current` rather-    /// than to a literal gate means the next bump either keeps working or fails-    /// here, on the host, in `make test-core`.-    @Test("The seeder accepts the gate the app actually runs")-    func repositorySeederAtCurrentGate() async throws {-        let root = FileManager.default.temporaryDirectory-            .appending(path: "asterism-scale-seeder-current-\(UUID().uuidString)", directoryHint: .isDirectory)-        defer { try? FileManager.default.removeItem(at: root) }-        let configuration = LibraryConfiguration(rootDirectory: root)-        try FileManager.default.createDirectory(-            at: configuration.v4StoreURL.deletingLastPathComponent(), withIntermediateDirectories: true)--        let container = try LibraryRepository.openV4Container(at: configuration.v4StoreURL)-        let repository = LibraryRepository.makeRepository(-            configuration, container, .current, SystemRepositoryClock(), ModelContextSaveStrategy())-        try await repository.seedM2PerformanceFixture()--        let counts = try await repository.debugCounts()-        #expect(counts.entries == 20_000)-        #expect(counts.works == 2_000)-        #expect(counts.sites == 6)-        #expect(counts.titlePatterns == 2)-    }--    @Test("Performance signpost names stay compatible with the physical hooks")-    func signpostNames() {-        #expect(M2PerformanceSignposts.subsystem == "me.nore.ig.Asterism")-        #expect(M2PerformanceSignposts.category == "M2Performance")-        #expect(M2PerformanceSignposts.recentPublication == "RecentPublication")-    }--    @Test("The 5,000-entry Site produces a complete deterministic projection")-    func targetSiteProjection() throws {-        let fixture = try M2ScaleFixture.make()-        let targetEntries = fixture.entries.filter { $0.hostname == fixture.targetHostname }-        let targetWorks = fixture.works-            .filter { $0.siteHostname == fixture.targetHostname }-            .map {-                WorkMatchCandidate(-                    id: $0.id,-                    lastParsedTitle: $0.lastParsedTitle,-                    displayTitle: $0.displayTitle-                )-            }--        let first = try TitleProjectionPlanner.planInitialTeaching(-            patternDefinition: fixture.segmentEdits.last!,-            entries: targetEntries,-            existingWorks: targetWorks-        )-        let second = try TitleProjectionPlanner.planInitialTeaching(-            patternDefinition: fixture.segmentEdits.last!,-            entries: targetEntries.reversed(),-            existingWorks: targetWorks.reversed()-        )--        #expect(first.entryProjections.count == 5_000)-        #expect(first.worksToCreate.isEmpty)-        #expect(!first.hasAmbiguity)-        #expect(first == second)-    }-}--private struct M2ScaleFixture {-    let targetHostname: String-    let entries: [EntrySnapshot]-    let works: [WorkBasisEntry]-    let siteEntryCounts: [String: Int]-    let siteWorkCounts: [String: Int]-    let segmentEdits: [PatternDefinition]-    let phraseEdits: [PatternDefinition]--    static func make() throws -> M2ScaleFixture {-        let entryCounts = [-            "scale.test": 5_000,-            "archive.test": 4_200,-            "articles.test": 3_600,-            "serial.test": 3_000,-            "notes.test": 2_400,-            "essays.test": 1_800,-        ]-        let workCounts = [-            "scale.test": 500,-            "archive.test": 400,-            "articles.test": 350,-            "serial.test": 300,-            "notes.test": 250,-            "essays.test": 200,-        ]-        guard entryCounts.values.reduce(0, +) == 20_000,-              workCounts.values.reduce(0, +) == 2_000,-              entryCounts.values.count(where: { $0 == 5_000 }) == 1 else {-            throw ScaleFixtureError.invalidCardinality-        }--        let targetHostname = "scale.test"-        let activePatternID = fixedUUID(namespace: 1, index: 1)-        let none = try FieldProvenance(kind: .none)-        let manual = try FieldProvenance(kind: .manual)-        let parsed = try FieldProvenance(-            kind: .pattern,-            patternID: activePatternID,-            patternVersion: 2-        )--        var works: [WorkBasisEntry] = []-        var workIDsBySite: [String: [UUID]] = [:]-        var globalWorkIndex = 0-        for hostname in workCounts.keys.sorted() {-            guard let count = workCounts[hostname], count > 0 else {-                throw ScaleFixtureError.invalidSiteCount(hostname)-            }-            var siteIDs: [UUID] = []-            siteIDs.reserveCapacity(count)-            for localIndex in 0..<count {-                let id = fixedUUID(namespace: 2, index: globalWorkIndex)-                let title = "Work \(localIndex)"-                works.append(-                    WorkBasisEntry(-                        id: id,-                        displayTitle: title,-                        lastParsedTitle: localIndex.isMultiple(of: 2) ? title : nil,-                        titleProvenance: localIndex.isMultiple(of: 3) ? .manual : .parsed,-                        siteHostname: hostname,-                        createdAt: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex)),-                        modifiedAt: Date(timeIntervalSince1970: TimeInterval(globalWorkIndex + 1))-                    )-                )-                siteIDs.append(id)-                globalWorkIndex += 1-            }-            workIDsBySite[hostname] = siteIDs-        }--        var entries: [EntrySnapshot] = []-        entries.reserveCapacity(20_000)-        var globalEntryIndex = 0-        for hostname in entryCounts.keys.sorted() {-            guard let count = entryCounts[hostname], count > 0,-                  let siteWorkIDs = workIDsBySite[hostname], !siteWorkIDs.isEmpty else {-                throw ScaleFixtureError.invalidSiteCount(hostname)-            }-            for localIndex in 0..<count {-                let isTarget = hostname == targetHostname-                let state = isTarget ? localIndex % 4 : 2-                let chapterProvenance = state == 0 ? manual : (state == 1 || state == 3 ? parsed : none)-                let assignmentProvenance = state == 0 ? manual : (state == 1 || state == 3 ? parsed : none)-                let workID = state == 0 || state == 1-                    ? siteWorkIDs[localIndex % siteWorkIDs.count]-                    : nil-                let chapterTitle = state == 0 || state == 1 || state == 3-                    ? "Chapter \(localIndex)"-                    : nil-                let timestamp = Date(timeIntervalSince1970: TimeInterval(globalEntryIndex))-                entries.append(-                    EntrySnapshot(-                        id: fixedUUID(namespace: 3, index: globalEntryIndex),-                        captureTitle: "Chapter \(localIndex) - Work \(localIndex % siteWorkIDs.count) | \(hostname)",-                        captureTitleSource: localIndex.isMultiple(of: 2) ? .host : .manual,-                        rawURLString: "https://\(hostname)/entry/\(localIndex)",-                        canonicalURLString: nil,-                        hostname: hostname,-                        entryIdentityKey: "https://\(hostname)/entry/\(localIndex)",-                        identityKeyVersion: 1,-                        chapterTitle: chapterTitle,-                        chapterTitleProvenance: chapterProvenance,-                        note: localIndex.isMultiple(of: 17) ? "Representative note" : "",-                        rating: localIndex.isMultiple(of: 19) ? .up : nil,-                        firstCapturedAt: timestamp,-                        lastSharedAt: timestamp,-                        modifiedAt: timestamp,-                        workID: workID,-                        workAssignmentProvenance: assignmentProvenance,-                        intentionallyUnattached: false-                    )-                )-                globalEntryIndex += 1-            }-        }--        let segmentDefinition = PatternDefinition.segment(-            work: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),-            ignored: [try SegmentPositionSpec(origin: .end, offset: 0)]-        )-        let segmentEdits = Array(repeating: segmentDefinition, count: 10)-        let phraseEdits = (0..<10).map { edit in-            PatternDefinition.phrase(-                prefix: "Read \(edit): ",-                separator: " from ",-                suffix: ".",-                order: edit.isMultiple(of: 2) ? .chapterThenWork : .workThenChapter-            )-        }--        return M2ScaleFixture(-            targetHostname: targetHostname,-            entries: entries,-            works: works,-            siteEntryCounts: entryCounts,-            siteWorkCounts: workCounts,-            segmentEdits: segmentEdits,-            phraseEdits: phraseEdits-        )-    }--    static func isActionable(_ entry: EntrySnapshot) -> Bool {-        ActionabilityEvaluator.isActionable(-            chapterTitle: entry.chapterTitle,-            chapterProvenance: entry.chapterTitleProvenance,-            workID: entry.workID,-            assignmentProvenance: entry.workAssignmentProvenance,-            intentionallyUnattached: entry.intentionallyUnattached-        )-    }--    private static func fixedUUID(namespace: Int, index: Int) -> UUID {-        let value = String(format: "%012llX", UInt64(index))-        guard let id = UUID(uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)) else {-            preconditionFailure("The deterministic scale UUID format must remain valid")-        }-        return id-    }-}--private enum ScaleFixtureError: Error {-    case invalidCardinality-    case invalidSiteCount(String)-}
Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift Deleted +0 / -508
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swiftdeleted file mode 100644index c82332a..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixture.swift+++ /dev/null@@ -1,508 +0,0 @@-import Foundation-@testable import AsterismCore--// MARK: - M3 Scale Fixture (Design §12, Requirement 7.5)--/// Deterministic fixture for the M3 URL-identity scale tests.-///-/// The fixture generates exactly 5,000 Entries for one URL-taught Site-/// with the distribution mandated by Requirements 7.5 and 7.6:-/// - 3,000 separate-component bracket successes-/// - 1,000 combined-template successes-/// - 400 extraction failures-/// - 300 Entries in Work-collision groups-/// - 300 Entries in Work-split groups-/// - 100 identity-key collision pairs among successful groups-///-/// Environment is accepted but does not influence the deterministic output —-/// it validates that both Development and Personal produce identical fixtures-/// without accessing each other's libraries (Requirement 7.7).-struct M3ScaleFixture {-    let hostname: ExactScalarString-    let entries: [M3ScaleEntry]-    let bracketSuccesses: [M3ScaleEntry]-    let templateSuccesses: [M3ScaleEntry]-    let extractionFailures: [M3ScaleEntry]-    let collisionEntries: [M3ScaleEntry]-    let splitEntries: [M3ScaleEntry]-    let keyCollisionPairs: [M3KeyCollisionPair]-    let collisionGroups: [M3CollisionGroup]-    let splitGroups: [M3SplitGroup]-    let bracketRuleDefinition: URLRuleDefinition-    let templateRuleDefinition: URLRuleDefinition-    let ruleEdits: [URLRuleDefinition]--    // MARK: - Deterministic URL Structure-    //-    // Target Site: scale.test-    //-    // Bracket rule: .workAndSequence where-    //   Work = path component bracketed by "series" (left) and "chapter" (right)-    //   Sequence = path component bracketed by "chapter" (left) and path end (right)-    //-    // Template rule: .combined with bracket locator on "mixed" component-    //   prefix "w", separator "-ch", suffix "", order .workThenSequence-    //-    // Bracket URL pattern: https://scale.test/series/{work}/chapter/{seq}-    // Template URL pattern: https://scale.test/content/mixed/w{work}-ch{seq}-    // Failure patterns: URLs missing the required bracket anchors-    //-    // Collision: multiple Works assigned Entries with the same bracket Work identity-    // Split: one Work's Entries yield different bracket Work identities-    // Key collision: two Entries with same (hostname, workIdentity, sequence) tuple--    /// Creates the deterministic fixture.-    static func make() throws -> M3ScaleFixture {-        let hostname = ExactScalarString("scale.test")--        // Define the rules-        let bracketRule = URLRuleDefinition.workAndSequence(-            work: URLFieldSelector(-                locator: .pathBracketed(-                    left: .literal(ExactScalarString("series")),-                    right: .literal(ExactScalarString("chapter"))-                )-            ),-            sequence: URLFieldSelector(-                locator: .pathBracketed(-                    left: .literal(ExactScalarString("chapter")),-                    right: .end-                )-            )-        )--        let templateRule = URLRuleDefinition.combined(-            locator: .pathBracketed(-                left: .literal(ExactScalarString("mixed")),-                right: .end-            ),-            template: URLTwoFieldTemplate(-                prefix: ExactScalarString("w"),-                separator: ExactScalarString("-ch"),-                suffix: ExactScalarString(""),-                order: .workThenSequence-            )-        )--        // Build all categories deterministically--        // 1. Bracket successes (3,000 entries)-        //    Each has: https://scale.test/series/{workID}/chapter/{seqID}-        //    600 distinct Works × 5 Entries each = 3,000-        var bracketEntries: [M3ScaleEntry] = []-        bracketEntries.reserveCapacity(3_000)-        var bracketWorks: [UUID] = []-        bracketWorks.reserveCapacity(600)--        for workIndex in 0..<600 {-            let workID = fixedUUID(namespace: 10, index: workIndex)-            bracketWorks.append(workID)-            for seqIndex in 0..<5 {-                let entryIndex = workIndex * 5 + seqIndex-                let entryID = fixedUUID(namespace: 11, index: entryIndex)-                let rawURL = ExactScalarString(-                    "https://scale.test/series/work\(workIndex)/chapter/\(seqIndex + 1)"-                )-                bracketEntries.append(M3ScaleEntry(-                    id: entryID,-                    rawURL: rawURL,-                    captureTitle: ExactScalarString("Work \(workIndex) Ch \(seqIndex + 1) | scale.test"),-                    workID: workID,-                    firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(entryIndex))-                ))-            }-        }--        // 2. Combined-template successes (1,000 entries)-        //    Each has: https://scale.test/content/mixed/w{workID}-ch{seqID}-        //    200 distinct Works × 5 Entries each = 1,000-        var templateEntries: [M3ScaleEntry] = []-        templateEntries.reserveCapacity(1_000)-        var templateWorks: [UUID] = []-        templateWorks.reserveCapacity(200)--        for workIndex in 0..<200 {-            let workID = fixedUUID(namespace: 12, index: workIndex)-            templateWorks.append(workID)-            for seqIndex in 0..<5 {-                let entryIndex = workIndex * 5 + seqIndex-                let entryID = fixedUUID(namespace: 13, index: entryIndex)-                let rawURL = ExactScalarString(-                    "https://scale.test/content/mixed/w\(workIndex)-ch\(seqIndex + 1)"-                )-                templateEntries.append(M3ScaleEntry(-                    id: entryID,-                    rawURL: rawURL,-                    captureTitle: ExactScalarString("Mixed \(workIndex) Ch \(seqIndex + 1) | scale.test"),-                    workID: workID,-                    firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(3_000 + entryIndex))-                ))-            }-        }--        // 3. Extraction failures (400 entries)-        //    URLs that structurally differ: missing the "series" or "chapter" anchor-        var failureEntries: [M3ScaleEntry] = []-        failureEntries.reserveCapacity(400)--        for failIndex in 0..<400 {-            let entryID = fixedUUID(namespace: 14, index: failIndex)-            // Vary the malformation pattern for the bracket rule:-            // Work locator expects left:"series", right:"chapter"-            // Sequence locator expects left:"chapter", right:.end-            let rawURL: ExactScalarString-            switch failIndex % 5 {-            case 0:-                // Missing both anchors — no "series" or "chapter" at all-                rawURL = ExactScalarString("https://scale.test/posts/article\(failIndex)/page/\(failIndex)")-            case 1:-                // Has "series" but no "chapter" after the selected component-                rawURL = ExactScalarString("https://scale.test/series/work\(failIndex)/page/\(failIndex)")-            case 2:-                // Empty component between brackets (Work extraction → .emptyComponent)-                rawURL = ExactScalarString("https://scale.test/series//chapter/\(failIndex)")-            case 3:-                // Ambiguous Work bracket: two components each immediately right of "series"-                // /series/series/x/chapter/y → "series" at [0],[1]; for Work bracket:-                //   index 1 ("series"): left=comp[0]="series"✓, right=comp[2]="x"≠"chapter"✗-                //   index 2 ("x"): left=comp[1]="series"✓, right=comp[3]="chapter"✓ → 1 match only-                // Actually need truly ambiguous. Use /series/a/series/b/chapter/c:-                //   comp=[series,a,series,b,chapter,c]-                //   Work(left:"series",right:"chapter"):-                //     idx1("a"): left=comp[0]="series"✓, right=comp[2]="series"≠"chapter"✗-                //     idx3("b"): left=comp[2]="series"✓, right=comp[4]="chapter"✓ → 1 match-                //   That still works... Use explicit double match:-                //   /series/x/chapter/series/y/chapter/z-                //   comp=[series,x,chapter,series,y,chapter,z]-                //   Work(left:"series",right:"chapter"):-                //     idx1("x"): left=comp[0]="series"✓, right=comp[2]="chapter"✓ → match-                //     idx4("y"): left=comp[3]="series"✓, right=comp[5]="chapter"✓ → match-                //   Two matches → .ambiguousBracket(2) ✓-                rawURL = ExactScalarString(-                    "https://scale.test/series/x\(failIndex)/chapter/series/y\(failIndex)/chapter/z\(failIndex)"-                )-            default:-                // Path too short: only root and "series" with nothing bracketed-                rawURL = ExactScalarString("https://scale.test/series")-            }-            failureEntries.append(M3ScaleEntry(-                id: entryID,-                rawURL: rawURL,-                captureTitle: ExactScalarString("Failure \(failIndex) | scale.test"),-                workID: nil,-                firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(4_000 + failIndex))-            ))-        }--        // 4. Collision entries (300 entries)-        //    Multiple Works share the same extracted Work identity.-        //    30 collision groups × 2 Works each × 5 Entries per Work = 300-        var collisionEntryList: [M3ScaleEntry] = []-        collisionEntryList.reserveCapacity(300)-        var collisionGroupList: [M3CollisionGroup] = []-        collisionGroupList.reserveCapacity(30)--        for groupIndex in 0..<30 {-            let sharedIdentity = ExactScalarString("collision\(groupIndex)")-            var groupWorkIDs: [UUID] = []-            var groupEntryIDs: [UUID] = []--            for workOffset in 0..<2 {-                let workID = fixedUUID(namespace: 15, index: groupIndex * 2 + workOffset)-                groupWorkIDs.append(workID)--                for entryOffset in 0..<5 {-                    let entryIndex = groupIndex * 10 + workOffset * 5 + entryOffset-                    let entryID = fixedUUID(namespace: 16, index: entryIndex)-                    groupEntryIDs.append(entryID)-                    // All Entries in this group extract "collision{groupIndex}" as Work identity-                    let rawURL = ExactScalarString(-                        "https://scale.test/series/collision\(groupIndex)/chapter/c\(entryIndex)"-                    )-                    collisionEntryList.append(M3ScaleEntry(-                        id: entryID,-                        rawURL: rawURL,-                        captureTitle: ExactScalarString(-                            "Collision \(groupIndex) Entry \(entryIndex) | scale.test"-                        ),-                        workID: workID,-                        firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(4_400 + entryIndex))-                    ))-                }-            }--            collisionGroupList.append(M3CollisionGroup(-                identity: sharedIdentity,-                workIDs: groupWorkIDs,-                entryIDs: groupEntryIDs-            ))-        }--        // 5. Split entries (300 entries)-        //    One Work's Entries yield multiple identities under the bracket rule.-        //    30 split groups × 10 Entries per Work (split into 2+ identities) = 300-        var splitEntryList: [M3ScaleEntry] = []-        splitEntryList.reserveCapacity(300)-        var splitGroupList: [M3SplitGroup] = []-        splitGroupList.reserveCapacity(30)--        for groupIndex in 0..<30 {-            let workID = fixedUUID(namespace: 17, index: groupIndex)-            var groupEntryIDs: [UUID] = []-            // Each split Work has entries yielding two different identities-            let identityA = ExactScalarString("splitA\(groupIndex)")-            let identityB = ExactScalarString("splitB\(groupIndex)")--            for entryOffset in 0..<10 {-                let entryIndex = groupIndex * 10 + entryOffset-                let entryID = fixedUUID(namespace: 18, index: entryIndex)-                groupEntryIDs.append(entryID)-                // First 5 yield identityA, last 5 yield identityB-                let identity = entryOffset < 5 ? "splitA\(groupIndex)" : "splitB\(groupIndex)"-                let rawURL = ExactScalarString(-                    "https://scale.test/series/\(identity)/chapter/s\(entryIndex)"-                )-                splitEntryList.append(M3ScaleEntry(-                    id: entryID,-                    rawURL: rawURL,-                    captureTitle: ExactScalarString(-                        "Split \(groupIndex) Entry \(entryOffset) | scale.test"-                    ),-                    workID: workID,-                    firstCapturedAt: Date(timeIntervalSince1970: TimeInterval(4_700 + entryIndex))-                ))-            }--            splitGroupList.append(M3SplitGroup(-                workID: workID,-                identities: [identityA, identityB],-                entryIDs: groupEntryIDs-            ))-        }--        // 6. Key-collision pairs (100 pairs among successful groups)-        //    Two Entries produce the same (hostname, workIdentity, sequence) key.-        //    They come from the bracket-success pool: pairs share the same work+seq-        //    but have different raw URLs (e.g., trailing query or extra empty component).-        //-        //    We use the first 100 bracket entries and create 100 "shadow" entries that-        //    produce the same identity key via identical bracket extraction but with a-        //    trailing query that doesn't affect extraction.-        //-        //    However, we already counted those 3,000 bracket entries. The key-collision-        //    pairs must be *inside* the successful groups. We designate certain bracket-        //    entries as key-collision pairs: entries at indices 0,5 / 10,15 / ... share-        //    the same (work, sequence) because they have different raw URLs but the-        //    bracket extraction yields the same identity.-        //-        //    Simpler: among bracket successes, we create 100 pairs where two entries-        //    share (workIdentity, sequence). We achieve this by having two entries-        //    point to the same (workN, chapter1) but via different URL paths that-        //    bracket-extract identically — e.g., one with and one without a trailing slash.-        //-        //    Actually: bracket entries are https://scale.test/series/workN/chapter/K.-        //    If two entries share workN and K, they collide. We'll designate that for-        //    the first 100 bracket Works, entries at seqIndex 0 and seqIndex 0 of a-        //    "shadow" URL both extract to the same key.-        //-        //    Best approach: within the 3,000 bracket entries, arrange that 100 pairs-        //    (work0/ch1, work0/ch1+query), (work1/ch1, work1/ch1+query), etc. collide.-        //    But we want disjoint entries. Let's use: entries at offset 0 and a specially--        //    crafted entry at the END of the bracket pool both extract to the same key.-        //-        //    Simplest: since we have 600 works × 5 entries = 3,000, we can designate-        //    that for works 0–99, entries at seq 1 and seq 1 collide by having the-        //    SAME path but a different query string (which is ignored by bracket rule).-        //    That requires two entries with same bracket extraction. But we need them-        //    to be distinct entries in the fixture...-        //-        //    Decision: we reserve the last 200 of the 3,000 bracket entries as 100 pairs.-        //    Entries at indices 2800–2999 are paired: (2800,2801), (2802,2803), etc.-        //    Each pair shares the same URL path (and therefore same bracket extraction)-        //    but one has a query string "?v=1" and the other "?v=2".-        //    Both extract identically because the bracket rule only uses path components.-        var keyPairs: [M3KeyCollisionPair] = []-        keyPairs.reserveCapacity(100)--        // Rewrite the last 200 bracket entries (indices 2800–2999) to form 100 collision pairs-        // These entries are in works 560–599 (work 560 starts at index 2800 = 560*5)-        // We'll adjust their URLs to create pairs.-        for pairIndex in 0..<100 {-            let baseEntryIdx = 2_800 + pairIndex * 2-            let entryA = bracketEntries[baseEntryIdx]-            let entryB = bracketEntries[baseEntryIdx + 1]--            // Make both entries extract the same key: same work identity and same sequence-            // The work identity comes from workIndex = baseEntryIdx / 5-            // We need them to share workIdentity AND sequence.-            // Current: work=2800/5=560, seq=0+1=1 and work=560, seq=1+1=2 — different sequences!-            // Fix: rewrite both URLs to have the same work/seq path but differ only in query.-            let workIdx = 560 + pairIndex / 2-            let seqVal = (pairIndex % 2) + 1-            let workIdentity = "work\(workIdx)"-            let sequence = "\(seqVal)"--            let urlA = ExactScalarString(-                "https://scale.test/series/\(workIdentity)/chapter/\(sequence)?src=a\(pairIndex)"-            )-            let urlB = ExactScalarString(-                "https://scale.test/series/\(workIdentity)/chapter/\(sequence)?src=b\(pairIndex)"-            )--            bracketEntries[baseEntryIdx] = M3ScaleEntry(-                id: entryA.id,-                rawURL: urlA,-                captureTitle: entryA.captureTitle,-                workID: entryA.workID,-                firstCapturedAt: entryA.firstCapturedAt-            )-            bracketEntries[baseEntryIdx + 1] = M3ScaleEntry(-                id: entryB.id,-                rawURL: urlB,-                captureTitle: entryB.captureTitle,-                workID: entryB.workID,-                firstCapturedAt: entryB.firstCapturedAt-            )--            // Compute the canonical key for verification-            let identity = try URLDerivedEntryIdentity(-                hostname: hostname,-                workIdentity: ExactScalarString(workIdentity),-                chapterSequence: ExactScalarString(sequence)-            )-            let key = EntryIdentityKeyV2Codec.encode(identity)-            keyPairs.append(M3KeyCollisionPair(key: key, entryIDs: [entryA.id, entryB.id]))-        }--        // Combine all entries-        let allEntries = bracketEntries + templateEntries + failureEntries-            + collisionEntryList + splitEntryList--        // Validate distribution-        guard allEntries.count == 5_000 else {-            throw M3ScaleFixtureError.invalidCardinality(-                reason: "Expected 5,000 entries but got \(allEntries.count)"-            )-        }--        // Build the 10-edit sequence (Design §8.7):-        // Variations on the bracket rule with slightly different anchors-        // to simulate the reader iterating through teaching edits.-        let edits = buildRuleEdits(bracketRule: bracketRule)--        return M3ScaleFixture(-            hostname: hostname,-            entries: allEntries,-            bracketSuccesses: bracketEntries,-            templateSuccesses: templateEntries,-            extractionFailures: failureEntries,-            collisionEntries: collisionEntryList,-            splitEntries: splitEntryList,-            keyCollisionPairs: keyPairs,-            collisionGroups: collisionGroupList,-            splitGroups: splitGroupList,-            bracketRuleDefinition: bracketRule,-            templateRuleDefinition: templateRule,-            ruleEdits: edits-        )-    }--    // MARK: - Edit sequence builder--    /// Produces 10 deterministic rule-edit variations that simulate successive reader edits.-    /// Each is a valid rule definition; the final edit is the canonical bracket rule.-    private static func buildRuleEdits(bracketRule: URLRuleDefinition) -> [URLRuleDefinition] {-        // Edits 0–8 use query-based locators or alternative bracket anchors that-        // extract different values from the same URLs. Edit 9 is the canonical rule.-        var edits: [URLRuleDefinition] = []-        edits.reserveCapacity(10)--        // Edits 0–4: Work-only rules selecting different path components-        for i in 0..<5 {-            edits.append(.work(-                locator: .pathBracketed(-                    left: i < 3 ? .literal(ExactScalarString("series")) : .start,-                    right: i < 3 ? .literal(ExactScalarString("chapter")) : .literal(ExactScalarString("series"))-                )-            ))-        }--        // Edits 5–8: workAndSequence with different anchor combinations-        for i in 5..<9 {-            let workLeft: PathAnchor = i.isMultiple(of: 2)-                ? .start-                : .literal(ExactScalarString("series"))-            let workRight: PathAnchor = .literal(ExactScalarString("chapter"))-            let seqLeft: PathAnchor = .literal(ExactScalarString("chapter"))-            let seqRight: PathAnchor = i.isMultiple(of: 2) ? .end : .literal(ExactScalarString("end\(i)"))--            edits.append(.workAndSequence(-                work: URLFieldSelector(locator: .pathBracketed(left: workLeft, right: workRight)),-                sequence: URLFieldSelector(locator: .pathBracketed(left: seqLeft, right: seqRight))-            ))-        }--        // Edit 9: the canonical bracket rule (matches the fixture's primary rule)-        edits.append(bracketRule)--        return edits-    }--    // MARK: - UUID generation--    /// Deterministic UUID from namespace + index. Matches the M2 pattern for consistency.-    private static func fixedUUID(namespace: Int, index: Int) -> UUID {-        let value = String(format: "%012llX", UInt64(index))-        guard let id = UUID(uuidString: String(format: "%08X-0000-4000-8000-%@", namespace, value)) else {-            preconditionFailure("The deterministic M3 scale UUID format must remain valid")-        }-        return id-    }-}--// MARK: - Supporting Types--struct M3ScaleEntry: Equatable, Sendable {-    let id: UUID-    let rawURL: ExactScalarString-    let captureTitle: ExactScalarString-    let workID: UUID?-    let firstCapturedAt: Date-}--struct M3KeyCollisionPair: Equatable, Sendable {-    let key: String-    let entryIDs: [UUID]-}--struct M3CollisionGroup: Equatable, Sendable {-    /// The shared Work identity value-    let identity: ExactScalarString-    /// Multiple distinct Works that yield this identity-    let workIDs: [UUID]-    /// All Entries across those Works-    let entryIDs: [UUID]-}--struct M3SplitGroup: Equatable, Sendable {-    /// The single Work whose Entries yield multiple identities-    let workID: UUID-    /// The distinct identities found-    let identities: [ExactScalarString]-    /// All Entry IDs in this split Work-    let entryIDs: [UUID]-}--enum M3ScaleFixtureError: Error, CustomStringConvertible {-    case notYetImplemented-    case invalidCardinality(reason: String)--    var description: String {-        switch self {-        case .notYetImplemented:-            "M3 scale fixture is not yet implemented (task 54)"-        case .invalidCardinality(let reason):-            "M3 scale fixture cardinality error: \(reason)"-        }-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift Deleted +0 / -383
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swiftdeleted file mode 100644index dbaa350..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M3ScaleFixtureTests.swift+++ /dev/null@@ -1,383 +0,0 @@-import Foundation-import Testing-@testable import AsterismCore--// MARK: - M3 Scale Fixture Tests (Design §12, Requirement 7.5)--/// Validates the deterministic M3 URL-identity scale fixture produces exactly-/// the 5,000-Entry distribution required by Design §12 and Requirement 7.5:-/// - 3,000 separate-component bracket successes-/// - 1,000 combined-template successes-/// - 400 extraction failures-/// - 300 Entries in Work-collision groups-/// - 300 Entries in Work-split groups-/// - 100 identity-key collision pairs inside successful groups-///-/// Environment isolation ensures Development and Personal produce identical-/// fixture values without accessing each other's libraries.-@Suite("M3 scale fixture", .serialized)-struct M3ScaleFixtureTests {--    // MARK: - Exact distribution shape--    @Test("Fixture produces exactly 5,000 Entries for the target Site")-    func exactEntryCount() throws {-        let fixture = try M3ScaleFixture.make()-        #expect(fixture.entries.count == 5_000)-    }--    @Test("Distribution matches: 3,000 bracket, 1,000 template, 400 failures, 300 collision, 300 split")-    func exactCategoryDistribution() throws {-        let fixture = try M3ScaleFixture.make()--        #expect(fixture.bracketSuccesses.count == 3_000)-        #expect(fixture.templateSuccesses.count == 1_000)-        #expect(fixture.extractionFailures.count == 400)-        #expect(fixture.collisionEntries.count == 300)-        #expect(fixture.splitEntries.count == 300)--        // All categories are disjoint and sum to 5,000-        let total = fixture.bracketSuccesses.count-            + fixture.templateSuccesses.count-            + fixture.extractionFailures.count-            + fixture.collisionEntries.count-            + fixture.splitEntries.count-        #expect(total == 5_000)-    }--    @Test("100 identity-key collision pairs exist among successful groups")-    func identityKeyCollisionPairs() throws {-        let fixture = try M3ScaleFixture.make()--        // Key collisions are pairs: 100 pairs = 200 Entries sharing 100 keys-        #expect(fixture.keyCollisionPairs.count == 100)-        for pair in fixture.keyCollisionPairs {-            #expect(pair.entryIDs.count == 2)-            #expect(!pair.key.isEmpty)-        }--        // All key-collision Entries belong to the successful groups-        let successfulIDs = Set(fixture.bracketSuccesses.map(\.id) + fixture.templateSuccesses.map(\.id))-        let collisionEntryIDs = Set(fixture.keyCollisionPairs.flatMap(\.entryIDs))-        #expect(collisionEntryIDs.isSubset(of: successfulIDs))-    }--    // MARK: - Bracket successes--    @Test("Every bracket-success Entry has a valid URL and a bracket rule extracts Work identity")-    func bracketExtractionSucceeds() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.bracketRuleDefinition--        for entry in fixture.bracketSuccesses {-            let extraction = try URLRuleApplicator.apply(rule, to: entry.rawURL)-            #expect(!extraction.workIdentity.isBlank)-            // Bracket rules produce Work identity; sequence depends on rule form-            if case .workAndSequence = rule {-                #expect(extraction.chapterSequence != nil)-                #expect(!extraction.chapterSequence!.isBlank)-            }-        }-    }--    @Test("Bracket entries use exact two-sided path anchors")-    func bracketAnchorsAreExactTwoSided() throws {-        let fixture = try M3ScaleFixture.make()-        guard case .workAndSequence(let work, let sequence) = fixture.bracketRuleDefinition else {-            Issue.record("Bracket rule must be .workAndSequence")-            return-        }-        // Both selectors use path-bracketed locators-        if case .pathBracketed(let left, let right) = work.locator {-            #expect(left != .end, "Left anchor must not be .end")-            #expect(right != .start, "Right anchor must not be .start")-        } else {-            Issue.record("Work selector must use pathBracketed locator")-        }-        if case .pathBracketed(let left, let right) = sequence.locator {-            #expect(left != .end, "Left anchor must not be .end")-            #expect(right != .start, "Right anchor must not be .start")-        } else {-            Issue.record("Sequence selector must use pathBracketed locator")-        }-    }--    // MARK: - Combined-template successes--    @Test("Every template-success Entry extracts both Work identity and chapter sequence via combined template")-    func templateExtractionSucceeds() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.templateRuleDefinition--        for entry in fixture.templateSuccesses {-            let extraction = try URLRuleApplicator.apply(rule, to: entry.rawURL)-            #expect(!extraction.workIdentity.isBlank)-            #expect(extraction.chapterSequence != nil)-            #expect(!extraction.chapterSequence!.isBlank)-        }-    }--    @Test("Template rule uses .combined with exact prefix/separator/suffix")-    func templateRuleIsValidCombined() throws {-        let fixture = try M3ScaleFixture.make()-        guard case .combined(let locator, let template) = fixture.templateRuleDefinition else {-            Issue.record("Template rule must be .combined")-            return-        }-        #expect(!template.separator.isBlank)-        // Locator must resolve a single path component-        if case .pathBracketed(let left, let right) = locator {-            #expect(left != .end)-            #expect(right != .start)-        } else if case .query = locator {-            // Also valid but unlikely for this fixture-        } else {-            Issue.record("Template locator must be pathBracketed or query")-        }-    }--    // MARK: - Extraction failures--    @Test("Every failure Entry produces a typed URLRuleApplicationError")-    func extractionFailuresAreTyped() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.bracketRuleDefinition--        for entry in fixture.extractionFailures {-            do {-                _ = try URLRuleApplicator.apply(rule, to: entry.rawURL)-                Issue.record("Expected extraction failure for Entry \(entry.id)")-            } catch let error as URLRuleApplicationError {-                // Typed failure is the requirement-                _ = error.description-            } catch {-                Issue.record("Unexpected error type: \(error)")-            }-        }-    }--    @Test("Failures have URLs that structurally differ from the bracket pattern")-    func failureURLsAreMalformedForRule() throws {-        let fixture = try M3ScaleFixture.make()--        // Failures should have various structural issues-        for entry in fixture.extractionFailures {-            // Every failure URL should still be parseable as HTTP(S)-            #expect(entry.rawURL.value.hasPrefix("https://"))-        }-    }--    // MARK: - Collision groups--    @Test("Collision entries form groups where multiple Works share one identity")-    func collisionGroupsHaveMultipleWorks() throws {-        let fixture = try M3ScaleFixture.make()--        #expect(!fixture.collisionGroups.isEmpty)-        for group in fixture.collisionGroups {-            #expect(group.workIDs.count >= 2,-                    "A collision group must have at least 2 Works sharing the identity")-            #expect(!group.identity.isBlank)-            #expect(!group.entryIDs.isEmpty)-        }--        // Total collision entries matches the 300 category-        let totalCollisionEntries = fixture.collisionGroups.reduce(0) { $0 + $1.entryIDs.count }-        #expect(totalCollisionEntries == 300)-    }--    @Test("Collision entries all extract the same Work identity within each group")-    func collisionEntriesShareIdentity() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.bracketRuleDefinition-        let entryByID = Dictionary(uniqueKeysWithValues: fixture.entries.map { ($0.id, $0) })--        for group in fixture.collisionGroups {-            for entryID in group.entryIDs {-                guard let entry = entryByID[entryID] else {-                    Issue.record("Missing entry \(entryID)")-                    continue-                }-                let extraction = try URLRuleApplicator.apply(rule, to: entry.rawURL)-                #expect(extraction.workIdentity == group.identity)-            }-        }-    }--    // MARK: - Split groups--    @Test("Split entries form groups where one Work's Entries yield multiple identities")-    func splitGroupsHaveMultipleIdentities() throws {-        let fixture = try M3ScaleFixture.make()--        #expect(!fixture.splitGroups.isEmpty)-        for group in fixture.splitGroups {-            #expect(group.identities.count >= 2,-                    "A split group must have at least 2 identities for one Work")-            #expect(!group.entryIDs.isEmpty)-        }--        // Total split entries matches the 300 category-        let totalSplitEntries = fixture.splitGroups.reduce(0) { $0 + $1.entryIDs.count }-        #expect(totalSplitEntries == 300)-    }--    @Test("Split entries assigned to one Work yield differing identities under the rule")-    func splitEntriesYieldDifferentIdentities() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.bracketRuleDefinition-        let entryByID = Dictionary(uniqueKeysWithValues: fixture.entries.map { ($0.id, $0) })--        for group in fixture.splitGroups {-            var identitiesFound: Set<ExactScalarString> = []-            for entryID in group.entryIDs {-                guard let entry = entryByID[entryID] else { continue }-                let extraction = try URLRuleApplicator.apply(rule, to: entry.rawURL)-                identitiesFound.insert(extraction.workIdentity)-            }-            #expect(identitiesFound.count >= 2,-                    "Expected multiple identities but got \(identitiesFound.count)")-        }-    }--    // MARK: - Identity-key collision pairs--    @Test("Key-collision pairs have distinct Entry IDs but identical V2 identity keys")-    func keyCollisionPairsShareCanonicalKey() throws {-        let fixture = try M3ScaleFixture.make()-        let rule = fixture.bracketRuleDefinition-        let entryByID = Dictionary(uniqueKeysWithValues: fixture.entries.map { ($0.id, $0) })-        let hostname = fixture.hostname--        for pair in fixture.keyCollisionPairs {-            #expect(pair.entryIDs[0] != pair.entryIDs[1])--            var keys: [String] = []-            for entryID in pair.entryIDs {-                guard let entry = entryByID[entryID] else {-                    Issue.record("Missing entry \(entryID)")-                    continue-                }-                let extraction = try URLRuleApplicator.apply(rule, to: entry.rawURL)-                guard let sequence = extraction.chapterSequence else {-                    Issue.record("Key-collision entry must have a chapter sequence")-                    continue-                }-                let identity = try URLDerivedEntryIdentity(-                    hostname: hostname,-                    workIdentity: extraction.workIdentity,-                    chapterSequence: sequence-                )-                keys.append(EntryIdentityKeyV2Codec.encode(identity))-            }-            #expect(keys.count == 2)-            #expect(keys[0] == keys[1], "Key-collision pair must produce identical keys")-            #expect(keys[0] == pair.key)-        }-    }--    // MARK: - Determinism--    @Test("Fixture is fully deterministic: two invocations produce byte-identical values")-    func deterministicReproduction() throws {-        let first = try M3ScaleFixture.make()-        let second = try M3ScaleFixture.make()--        #expect(first.entries.count == second.entries.count)-        #expect(first.entries.first?.id == second.entries.first?.id)-        #expect(first.entries.last?.id == second.entries.last?.id)-        #expect(first.entries.first?.rawURL == second.entries.first?.rawURL)-        #expect(first.entries.last?.rawURL == second.entries.last?.rawURL)--        #expect(first.bracketSuccesses.count == second.bracketSuccesses.count)-        #expect(first.templateSuccesses.count == second.templateSuccesses.count)-        #expect(first.extractionFailures.count == second.extractionFailures.count)-        #expect(first.collisionEntries.count == second.collisionEntries.count)-        #expect(first.splitEntries.count == second.splitEntries.count)-        #expect(first.keyCollisionPairs.count == second.keyCollisionPairs.count)-    }--    // MARK: - Fixture isolation-    //-    // The old relational-references Req 7.7 environment-isolation cases lived here-    // and compared two fixtures built with different `LibraryEnvironment` cases.-    // With the enum gone the comparison was `make()` against `make()`, and the-    // isolation invariant it stood for — Development and Personal never resolving-    // to the same App Group — is now `scripts/verify-identity.sh` check 1's job.--    @Test("Fixture hostnames are distinct from any real library path")-    func fixtureHostnamesDoNotOverlapLiveLibrary() throws {-        let fixture = try M3ScaleFixture.make()--        // The fixture's hostname must not collide with real hostnames-        #expect(fixture.hostname == ExactScalarString("scale.test"))-    }--    // MARK: - URL rule edit sequence--    @Test("The 10 edit sequence produces valid distinct rule definitions")-    func editSequenceIsValidAndDistinct() throws {-        let fixture = try M3ScaleFixture.make()--        #expect(fixture.ruleEdits.count == 10)-        for edit in fixture.ruleEdits {-            // Each edit must be a valid rule-            try edit.validate(origin: .readerTaught, isCurrent: true)-        }--        // At least some edits should differ (bracketed selection varies)-        let uniqueEdits = Set(fixture.ruleEdits.map { "\($0)" })-        #expect(uniqueEdits.count >= 2, "Edit sequence should contain distinct rule variations")-    }--    @Test("Each edit 50 ms apart can produce a complete 5,000-Entry projection")-    func editsProduceFullProjection() throws {-        let fixture = try M3ScaleFixture.make()--        // At minimum, last edit must be projectable on all entries-        let lastEdit = fixture.ruleEdits.last!-        var successCount = 0-        var failureCount = 0-        for entry in fixture.entries {-            do {-                _ = try URLRuleApplicator.apply(lastEdit, to: entry.rawURL)-                successCount += 1-            } catch {-                failureCount += 1-            }-        }-        // The final edit is the canonical fixture rule, so its distribution-        // should match the designed successes/failures-        #expect(successCount + failureCount == 5_000)-    }--    // MARK: - Repository seeding--    @Test("Repository seeder persists one valid exact-scale M3 URL graph")-    func repositorySeeder() async throws {-        let root = FileManager.default.temporaryDirectory-            .appending(path: "asterism-m3-scale-seeder-\(UUID().uuidString)", directoryHint: .isDirectory)-        defer { try? FileManager.default.removeItem(at: root) }-        let configuration = LibraryConfiguration(rootDirectory: root)-        let repository = try await LibraryRepository.openForApp(-            configuration,-            capabilities: .m3-        )--        try await repository.seedM3PerformanceFixture()--        let counts = try await repository.debugCounts()-        #expect(counts.entries == 5_000)-        #expect(counts.sites == 1)-        // 600 bracket + 200 template + 60 collision + 30 split = 890 Works-        #expect(counts.works == 890)-    }--    // MARK: - Signpost compatibility--    @Test("M3 performance signpost names stay compatible with the physical hooks")-    func signpostNames() {-        #expect(M3PerformanceSignposts.subsystem == "me.nore.ig.Asterism")-        #expect(M3PerformanceSignposts.category == "M3Performance")-    }-}
Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift Deleted +0 / -276
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swiftdeleted file mode 100644index fa4c62f..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V1ToV2MigrationTests.swift+++ /dev/null@@ -1,276 +0,0 @@-import Foundation-import SwiftData-import Testing-@testable import AsterismCore-@testable import AsterismV1MigrationSupport--@Suite("V1 to V2 developer migration", .serialized)-struct V1ToV2MigrationTests {-    @Test("Representative V1 graph reopens as logically equal V2 with segment mapping")-    func representativeGraph() async throws {-        let directory = try MigrationTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try createRepresentativeV1Store(at: configuration.legacyStoreURL)-        let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)--        try await V1ToV2Migrator.migrate(-            configuration: configuration,-            processChecker: AlwaysClosedProcessChecker(),-            verifier: LogicalMigrationVerifier()-        )--        #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))-        #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))-        let reopened = try V2MigrationStore.readSnapshot(-            from: configuration.storeURL,-            capabilities: .m2_0-        )-        #expect(reopened == sourceBefore)-        #expect(reopened.titlePatterns.count == 1)-        guard case .segment(let work, let ignored) = reopened.titlePatterns[0].definition else {-            Issue.record("Expected migrated V1 pattern to use the V2 segment arm")-            return-        }-        #expect(work == (try SegmentRangeSpec(origin: .start, offset: 1, length: 1)))-        #expect(ignored == [try SegmentPositionSpec(origin: .end, offset: 0)])--        let sourceAfter = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)-        #expect(sourceAfter == sourceBefore)-    }--    @Test("Existing V2 destination is refused without changing either store")-    func existingDestinationRefused() async throws {-        let directory = try MigrationTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try createRepresentativeV1Store(at: configuration.legacyStoreURL)-        try FileManager.default.createDirectory(-            at: configuration.storeURL.deletingLastPathComponent(),-            withIntermediateDirectories: true-        )-        let evidence = Data("existing V2 evidence".utf8)-        try evidence.write(to: configuration.storeURL)-        let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)--        await #expect(throws: V1ToV2MigrationError.self) {-            try await V1ToV2Migrator.migrate(-                configuration: configuration,-                processChecker: AlwaysClosedProcessChecker(),-                verifier: LogicalMigrationVerifier()-            )-        }--        #expect(try Data(contentsOf: configuration.storeURL) == evidence)-        #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)-    }--    @Test("Destination created while lock acquisition waits is preserved")-    func destinationCreatedWhileWaitingForLockIsPreserved() async throws {-        let directory = try MigrationTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try createRepresentativeV1Store(at: configuration.legacyStoreURL)-        let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)-        var heldLease: LockLease? = try await CrossProcessLibraryLock.acquire(-            mode: .exclusive,-            at: configuration.lockURL,-            timeout: .seconds(1)-        )-        let observedLockAcquirer = ObservedMigrationLockAcquirer()-        let migration = Task {-            try await V1ToV2Migrator.migrate(-                configuration: configuration,-                processChecker: AlwaysClosedProcessChecker(),-                verifier: LogicalMigrationVerifier(),-                capabilities: .m2_0,-                lockAcquirer: observedLockAcquirer-            )-        }--        await observedLockAcquirer.waitUntilAcquisitionStarted()-        try V2MigrationStore.create(-            snapshot: sourceBefore,-            at: configuration.storeURL,-            capabilities: .m2_0-        )-        #expect(-            try V2MigrationStore.readSnapshot(-                from: configuration.storeURL,-                capabilities: .m2_0-            ) == sourceBefore-        )-        let markerEvidence = Data("concurrent migration readiness".utf8)-        try markerEvidence.write(to: configuration.markerURL)-        heldLease = nil--        do {-            try await migration.value-            Issue.record("Expected the migration waiter to refuse the concurrent destination")-        } catch let error as V1ToV2MigrationError {-            guard case .destinationExists(let url) = error else {-                Issue.record("Expected destinationExists, got \(error)")-                return-            }-            #expect(url == configuration.storeURL)-        }--        #expect(heldLease == nil)-        #expect(try Data(contentsOf: configuration.markerURL) == markerEvidence)-        #expect(-            try V2MigrationStore.readSnapshot(-                from: configuration.storeURL,-                capabilities: .m2_0-            ) == sourceBefore-        )-        #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)-    }--    @Test("Failed verification removes every partial V2 artifact and preserves V1")-    func failedVerificationCleanup() async throws {-        let directory = try MigrationTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try createRepresentativeV1Store(at: configuration.legacyStoreURL)-        let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)--        await #expect(throws: V1ToV2MigrationError.self) {-            try await V1ToV2Migrator.migrate(-                configuration: configuration,-                processChecker: AlwaysClosedProcessChecker(),-                verifier: AlwaysFailingMigrationVerifier()-            )-        }--        for url in V2MigrationStore.artifactURLs(for: configuration.storeURL) {-            #expect(!FileManager.default.fileExists(atPath: url.path))-        }-        #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))-        #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)-    }--    @Test("Open runtime process blocks migration before either store is touched")-    func runtimeProcessBlocksMigration() async throws {-        let directory = try MigrationTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try createRepresentativeV1Store(at: configuration.legacyStoreURL)-        let sourceBefore = try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL)--        await #expect(throws: V1ToV2MigrationError.self) {-            try await V1ToV2Migrator.migrate(-                configuration: configuration,-                processChecker: AlwaysOpenProcessChecker(),-                verifier: LogicalMigrationVerifier()-            )-        }--        #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))-        #expect(try V1StoreReader.readSnapshot(from: configuration.legacyStoreURL) == sourceBefore)-    }-}--private struct AlwaysClosedProcessChecker: MigrationRuntimeProcessChecking {-    func requireRuntimeProcessesClosed() throws {}-}--private struct AlwaysOpenProcessChecker: MigrationRuntimeProcessChecking {-    func requireRuntimeProcessesClosed() throws {-        throw V1ToV2MigrationError.runtimeProcessesOpen-    }-}--private struct AlwaysFailingMigrationVerifier: MigrationVerifying {-    func verify(source: LibraryBackupSnapshot, destination: LibraryBackupSnapshot) throws {-        throw V1ToV2MigrationError.verificationFailed(reason: "injected verification failure")-    }-}--private actor ObservedMigrationLockAcquirer: MigrationLockAcquiring {-    private var acquisitionStarted = false-    private var startWaiters: [CheckedContinuation<Void, Never>] = []--    func acquireExclusive(at url: URL, timeout: Duration) async throws -> LockLease {-        acquisitionStarted = true-        for waiter in startWaiters {-            waiter.resume()-        }-        startWaiters.removeAll()-        return try await CrossProcessLibraryLock.acquire(-            mode: .exclusive,-            at: url,-            timeout: timeout-        )-    }--    func waitUntilAcquisitionStarted() async {-        guard !acquisitionStarted else { return }-        await withCheckedContinuation { continuation in-            startWaiters.append(continuation)-        }-    }-}--private func createRepresentativeV1Store(at url: URL) throws {-    try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)-    let schema = Schema(versionedSchema: AsterismV1MigrationSupport.AsterismSchemaV1.self)-    let configuration = ModelConfiguration("AsterismV1", schema: schema, url: url, cloudKitDatabase: .none)-    let container = try ModelContainer(-        for: schema,-        migrationPlan: AsterismV1MigrationSupport.AsterismV1MigrationPlan.self,-        configurations: [configuration]-    )-    let context = ModelContext(container)-    let timestamp = Date(timeIntervalSince1970: 1_721_000_000.123)-    let site = AsterismV1MigrationSupport.Site(hostname: "example.com")-    site.modeRaw = SiteMode.taught.rawValue-    let pattern = AsterismV1MigrationSupport.TitlePattern(-        id: UUID(uuidString: "BBBBBBBB-BBBB-BBBB-BBBB-BBBBBBBBBBBB")!,-        version: 3,-        isActive: true,-        createdAt: timestamp,-        workAnchor: try SegmentRangeSpec(origin: .start, offset: 1, length: 1),-        junkAnchors: [try SegmentPositionSpec(origin: .end, offset: 0)],-        site: site-    )-    let work = AsterismV1MigrationSupport.Work(-        id: UUID(uuidString: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA")!,-        displayTitle: "A Work",-        siteHostname: site.hostname,-        timestamp: timestamp-    )-    work.lastParsedTitle = "A Work"-    work.titleProvenanceRaw = TitleProvenance.parsed.rawValue-    let entry = AsterismV1MigrationSupport.Entry(-        id: UUID(uuidString: "11111111-1111-1111-1111-111111111111")!,-        captureTitle: "Chapter 1 - A Work | Example",-        captureTitleSourceRaw: CaptureTitleSource.host.rawValue,-        rawURLString: "https://example.com/1",-        hostname: site.hostname,-        entryIdentityKey: "https://example.com/1",-        timestamp: timestamp,-        work: work-    )-    entry.chapterTitle = "Chapter 1"-    entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue-    entry.chapterPatternID = pattern.id-    entry.chapterPatternVersion = pattern.version-    entry.workAssignmentProvenanceRaw = FieldProvenanceKind.pattern.rawValue-    entry.workPatternID = pattern.id-    entry.workPatternVersion = pattern.version--    context.insert(site)-    context.insert(pattern)-    context.insert(work)-    context.insert(entry)-    try context.save()-}--private final class MigrationTemporaryDirectory {-    let url: URL--    init() throws {-        url = FileManager.default.temporaryDirectory.appending(-            path: "AsterismMigrationTests-\(UUID())",-            directoryHint: .isDirectory-        )-        try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)-    }--    deinit { try? FileManager.default.removeItem(at: url) }-}
Asterism/Asterism/UITestLaunchSupport.swift Modified +4 / -13
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex 540b53d..200571d 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -14,17 +14,11 @@ struct SystemProcessEnvironment: ProcessEnvironmentProviding { enum UITestFixtureKind: Equatable {     case untaught     case taught-    case scale     /// The 5,000-Entry composed M4 fixture (`seedM4PerformanceFixture`), which-    /// Req 5.1's Recent publish-to-interactive baseline is measured over. The-    /// `.scale` fixture cannot stand in for it: it carries a different graph-    /// (20,000 Entries, no composed teaching).-    ///-    /// There is no `.scaleM3`. `seedM3PerformanceFixture` guards `== .m3` while-    /// the app runs `.m4`, so the scenario could not seed under any current-    /// build; its only caller was the M3 device UI suite, deleted with the-    /// URL-teaching surface it drove. The fixture itself is still exercised-    /// directly, at `.m3`, by `M3ScaleFixtureTests`.+    /// Req 5.1's Recent publish-to-interactive baseline is measured over. It is+    /// the only scale fixture left: the earlier milestones' larger but+    /// composition-free graphs were removed with the device suites that drove+    /// them, and neither measured the composed teaching Req 5.1 names.     case scaleM4     /// The same 5,000-Entry composed fixture perturbed into one of Req 1.1's     /// tolerated states. Req 5.3 asks for Recent's publish-to-interactive path to@@ -69,7 +63,6 @@ enum UITestLaunchSupport {     static let runIDKey = "ASTERISM_UI_TEST_RUN_ID"     static let seededScenario = "seeded-m1"     static let seededTaughtScenario = "seeded-taught"-    static let seededScaleScenario = "seeded-scale-m2"     static let seededScaleM4Scenario = "seeded-scale-m4"     /// `seeded-scale-m4-<M4ToleratedFixtureState>` — the scale fixture in one     /// tolerated state. Keyed by the state's own raw value, so a fourth state@@ -96,8 +89,6 @@ enum UITestLaunchSupport {             fixture = .untaught         case seededTaughtScenario:             fixture = .taught-        case seededScaleScenario:-            fixture = .scale         case seededScaleM4Scenario:             fixture = .scaleM4         case seededComposedScenario:
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +3 / -16
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex a980632..6aff4ad 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -1114,19 +1114,6 @@ public final class AppLibraryModel {             return         } -        if fixture == .scale {-            #if DEBUG || ASTERISM_PERFORMANCE_TESTING-            try await repository.seedM2PerformanceFixture()-            Self.logger.debug("Seeded deterministic M2 performance fixture")-            return-            #else-            throw LibraryRepositoryError.invalidInput(-                operation: "preparing UI test fixture",-                reason: "scale fixtures require a performance-test build"-            )-            #endif-        }-         if case .scaleM4Tolerated(let state) = fixture {             #if DEBUG || ASTERISM_PERFORMANCE_TESTING             // Req 5.3: the same 5,000-Entry composed fixture, perturbed into one@@ -1147,9 +1134,9 @@ public final class AppLibraryModel {          if fixture == .scaleM4 {             #if DEBUG || ASTERISM_PERFORMANCE_TESTING-            // Req 5.1's Recent baseline is defined over this fixture: the-            // 5,000-Entry composed graph, which `.scale` (20,000 Entries, no-            // composed teaching) cannot stand in for.+            // Req 5.1's Recent baseline is defined over this fixture and no+            // other: the 5,000-Entry graph with composed teaching applied. A+            // larger but composition-free graph measures a different code path.             try await repository.seedM4PerformanceFixture()             Self.logger.debug("Seeded deterministic M4 composed performance fixture")             return
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Modified +0 / -8
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 39fd20c..4a3686c 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -926,14 +926,6 @@ struct IntegrationSafetyNetTests {         #expect(FileManager.default.fileExists(atPath: v2Dir.appending(path: "AsterismV1.sqlite").path))     } -    @Test("No V2 migration product is linked into the app target")-    func noV2MigrationProductLinked() async throws {-        // V1ToV2Migrator lives in AsterismV1MigrationSupport, a separate product.-        // It should NOT be linked by the app target at runtime.-        let migrationClass: AnyClass? = NSClassFromString("AsterismV1MigrationSupport.V1ToV2Migrator")-        #expect(migrationClass == nil, "V2 migration support should not be linked in the app target")-    }-     // MARK: - Backup V3 with URL identity fields (Req 1.21)      @Test("Backup V3 export includes URL-rule versions, identity, and sequence fields")
Asterism/AsterismTests/UITestLaunchSupportTests.swift Modified +0 / -20
diff --git a/Asterism/AsterismTests/UITestLaunchSupportTests.swift b/Asterism/AsterismTests/UITestLaunchSupportTests.swiftindex 7fbd110..431ae5f 100644--- a/Asterism/AsterismTests/UITestLaunchSupportTests.swift+++ b/Asterism/AsterismTests/UITestLaunchSupportTests.swift@@ -57,26 +57,6 @@ struct UITestLaunchSupportTests {         #expect(fixture == .taught)     } -    @Test("Scale launches use the same contained disposable root")-    func validScaleSeedRequest() {-        let request = UITestLaunchSupport.request(-            environmentProvider: StubProcessEnvironment(-                values: [-                    UITestLaunchSupport.scenarioKey: UITestLaunchSupport.seededScaleScenario,-                    UITestLaunchSupport.runIDKey: UUID().uuidString,-                ]-            ),-            temporaryDirectory: URL(filePath: "/tmp/asterism-launch-tests")-        )--        guard case .seeded(let configuration, let fixture) = request else {-            Issue.record("Expected a scale-seeded launch, got \(request)")-            return-        }-        #expect(fixture == .scale)-        #expect(configuration.rootDirectory.path.contains("/AsterismUITests/"))-    }-     /// Req 5.1's Recent baseline is defined over the M4 composed fixture, and     /// the scenario that reaches it is new. Named here so a typo in the scenario     /// string fails a unit test rather than a 20-iteration device run.
Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift Modified +5 / -7
diff --git a/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift b/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swiftindex d782df2..74f25de 100644--- a/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift+++ b/Asterism/AsterismUITests/M4ScaleRecentPerformanceUITests.swift@@ -4,13 +4,11 @@ import XCTest /// Req 5.1's second baseline: Recent publish-to-interactive over the /// **5,000-Entry M4 composed fixture**. ///-/// No harness produced that combination before. `UITestLaunchSupport` enumerated-/// five scenarios and none seeded `seedM4PerformanceFixture`; the existing M2-/// suite measures Recent over the 20,000-Entry M2 fixture, which is a different-/// graph, and both the M2 and M3 seeders throw against an `.m4` build anyway-/// (`M2PerformanceFixture.swift:36` guards `capabilities == .m2_3`,-/// `M3PerformanceFixture.swift:21` guards `.m3`, `AsterismCapabilities.swift:28`-/// is `.m4`). Repairing those suites would still not measure what Req 5.1 names.+/// No harness produced that combination before: `UITestLaunchSupport` enumerated+/// several scenarios and none seeded `seedM4PerformanceFixture`. The earlier+/// milestones' scale suites measured Recent over larger graphs that carried no+/// composed teaching, so repairing them would still not have measured what+/// Req 5.1 names; they have since been removed along with their fixtures. /// /// The measurement itself is a physical-device measurement and is skipped /// everywhere else. `seedsAndReachesRecent` is not: it runs on the simulator on
CHANGELOG.md Modified +12 / -2
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 23afbd9..8a10702 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -23,8 +23,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).   refuses to capture until the app has completed the migration, so a share   attempted before the first launch will fail closed rather than write to a   half-migrated library.-- **Backups now export as format `4/4`.** Older builds cannot read a `4/4`-  file. Existing `2/2` and `3/3` backups still import.+- **Backups now export as format `4/4`, and `4/4` is the only format that+  imports.** Older builds cannot read a `4/4` file, and this build can no longer+  read the `2/2` and `3/3` archives earlier builds wrote — the import paths for+  them were removed. If you are holding an archive written before the unified+  teaching release, restore it on a build at or before `52c6504` and re-export+  it at `4/4` before you rely on it. - **Teaching is now one screen.** A Site no longer has an either/or "ordinary"   or "Work-only" mode; title and URL knowledge compose, and each derived field   comes from whichever rule supplies it. Existing taught Sites carry over@@ -68,6 +72,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Resolving a rule an Entry cites now looks only among the rules owned by the Site record the Entry itself points at, instead of searching the union of every row sharing the hostname — with each record carrying its own reference, the ambiguity that union existed to bridge is gone. Which Site presents a screen is unchanged: mode, title cleaning, and offered actions still follow the hostname's current teaching, while a record's recorded provenance now survives any change in which row that is. - Recent and an Entry's detail screen now render a row whose cited title pattern cannot be resolved — marked as needing attention, with the cited pattern's identity kept visible as evidence — instead of the one unresolvable citation failing the whole feed or screen. Re-teaching the site remains available on such a row, because re-teaching is the repair. +### Removed++- Removed importing of the `2/2` and `3/3` backup formats. Native `4/4` is the only archive the app now reads; a `2/2` or `3/3` file is declined before anything is written, naming the format it found. The codecs, both format mappers and the frozen `3/3` reference validator went with them. This is the one removal in this release that can cost you something: an archive written before the unified teaching release is no longer restorable in place, so re-export any you are keeping (see Upgrading, above). The `4/4` export, its self-validation, and the import that reconciles by application UUID are all unchanged.+- Removed the V1→V2 migration tooling — the `AsterismV1MigrationSupport` package, the `AsterismMigrationTool` executable and the `make migrate-m1-to-m2` target. Nothing reached it: the guard that would have sent a V1 library to it lived on an open path the app and the share extension both stopped using several releases ago. A library that has been opened by any recent build is unaffected.+- Removed the M2 and M3 scale-performance fixtures, their harnesses, and the `make test-performance` device target. Development tooling only, no effect on the app. The signposts the surviving `make test-performance-m4-recent` measurement reads are untouched, and their names are now pinned by a test that runs in the default `make test-core`.+ ### Fixed  - Fixed a new library becoming permanently unopenable, and removed the first-run setup screen that caused it. A fresh install used to create its library and then withhold the marker that certifies it until you answered "import a backup or start empty" — a question with nothing behind it, since there was no library to import into yet. Anything writing in that gap left a library the app could no longer classify, and every later launch refused to open it with no way back short of deleting the app. The library is now marked the moment it is created, so a fresh install opens straight into an empty library and the question is gone. One consequence worth knowing: the share extension now works from first launch instead of waiting for that answer, so a page shared before you restore a backup will be in the library when you do — restoring still shows you what it is about to discard and still asks twice. Importing a backup is unchanged and still lives in Settings. A library that is nonempty but uncertified is still refused rather than guessed at, which is the case that check was written for; a certified library whose store file has gone now says so instead of quietly starting you over on an empty one.
CLAUDE.md Modified +6 / -6
diff --git a/CLAUDE.md b/CLAUDE.mdindex b24bc66..a364900 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -5,7 +5,6 @@ **Never run a target that touches the physical iPhone without warning the user and getting explicit approval in the same exchange.** This applies to: -- `make test-performance` - `make test-performance-m4-recent` - `make install`, `make run`, and any `xcodebuild ... -destination 'id=<udid>'` - any `xcrun devicectl device install` / `uninstall`@@ -21,10 +20,11 @@ answer it on the user's behalf.** It exists for CI. If you find yourself wanting to bypass it, that is the signal to ask the user instead.  **Why this rule exists.** During the `library-integrity-tolerance` work, a-`make test-performance` run was started against the user's daily-use iPhone-without telling them first. Their library came up empty afterwards, and they had-taken no backup because they did not know a device run was about to happen. The-test scenario turned out to be sandboxed to a temporary directory+`make test-performance` run (a device target since retired) was started against+the user's daily-use iPhone without telling them first. Their library came up+empty afterwards, and they had taken no backup because they did not know a+device run was about to happen. The test scenario turned out to be sandboxed+to a temporary directory (`UITestLaunchSupport` builds its `LibraryConfiguration` from a temp `rootDirectory`, never the App Group), so the suite was probably not the cause — but "I read the code and concluded it was safe" is not a substitute for asking.@@ -45,7 +45,7 @@ invocations where a target exists. - `make test` / `make test-ui` — full suites (simulator) - `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **Takes ~40 minutes**: the V4→V5 migration measurement (each sample ~17 s of migration plus a ~13 s reset that has to be committed and reopened to be a pre-pass graph at all — Q58 of `specs/relational-references`), plus the duplicate-reconciliation suite added by M4c. **The target is knowingly red**, so `RUNS=3` stops after the first run — see `docs/agent-notes/testing.md` for how to record 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`, `make test-performance-m4-recent` — **physical device, see above**+- `make test-performance-m4-recent` — **physical device, see above**  The `-m4` and `-m4-recent` targets are easy to confuse and only one of them is safe: `test-performance-m4` is a `swift test` run of the `AsterismCore` package on the host, while `test-performance-m4-recent` builds the `Personal` configuration and installs it over the real app on a phone. 
Makefile Modified +2 / -45
diff --git a/Makefile b/Makefileindex a3efa88..d49fcf9 100644--- a/Makefile+++ b/Makefile@@ -41,7 +41,6 @@ help: 	@echo "    test-quick  - Run the unit-test bundle only" 	@echo "    test        - Run the complete test suite serially" 	@echo "    test-ui     - Run the UI-test bundle serially"-	@echo "    test-performance - Run opt-in M2 scale measurements on a physical iPhone" 	@echo "    test-performance-m4 - Run opt-in M4 composed-teaching scale budgets (AsterismCore)" 	@echo "    test-performance-m4-recent - Run the M4 Recent publish baseline on a physical iPhone" 	@echo "    test-performance-chunks - Sweep the bulk chunk constant (AsterismCore, host only)"@@ -57,7 +56,6 @@ help: 	@echo "" 	@echo "  Utilities:" 	@echo "    verify-identity  - Check the configuration identity declarations agree"-	@echo "    migrate-m1-to-m2 - Explicitly copy V1 to fresh V2 (requires MIGRATION_ROOT)" 	@echo "    resolve          - Resolve Swift package dependencies" 	@echo "    devices          - List known physical devices" 	@echo "    clean            - Remove repository-local build artifacts"@@ -83,21 +81,6 @@ verify-identity: test-core: verify-identity 	$(PIPEFAIL) swift test --package-path Packages/AsterismCore --no-parallel $(if $(CORE_TEST),--filter '$(CORE_TEST)',) $(PIPE_PRETTY) -# MIGRATION_ROOT is required (Q15). The tool used to default to resolving the-# host Mac's App Group container from a compiled-in identifier; that identifier-# now lives in the app's bundle, which this tool cannot read, so the library's-# root is the tool's only honest input.-MIGRATION_ROOT ?=-.PHONY: migrate-m1-to-m2-migrate-m1-to-m2:-	@if [ -z "$(MIGRATION_ROOT)" ]; then \-		echo "Error: MIGRATION_ROOT is required."; \-		echo "Example: make migrate-m1-to-m2 MIGRATION_ROOT=/path/to/library"; \-		exit 2; \-	fi-	swift run --package-path Packages/AsterismCore AsterismMigrationTool \-		--root "$(MIGRATION_ROOT)"- .PHONY: build build-ios build: build-ios @@ -186,35 +169,9 @@ define device_run_warning 	echo "" endef -.PHONY: test-performance-test-performance:-	@if ! command -v jq >/dev/null 2>&1; then \-		echo "Error: jq is required for physical-device discovery."; \-		exit 1; \-	fi-	@if [ -z "$(DEVICE_ID)" ]; then \-		echo "Error: no paired physical iPhone found$(if $(DEVICE_MODEL), matching '$(DEVICE_MODEL)',)."; \-		exit 1; \-	fi-	$(device_run_warning)-	$(PIPEFAIL) TEST_RUNNER_ASTERISM_RUN_PHYSICAL_PERFORMANCE=1 xcodebuild test \-		-project $(PROJECT) \-		-scheme "Asterism Personal" \-		-destination 'id=$(DEVICE_ID)' \-		-configuration Personal \-		-derivedDataPath $(DERIVED_DATA) \-		-allowProvisioningUpdates \-		-only-testing:$(UI_TEST_BUNDLE)/M2ScalePerformanceUITests \-		-parallel-testing-enabled NO \-		-parallel-testing-worker-count 1 \-		-maximum-concurrent-test-device-destinations 1 \-		SWIFT_ACTIVE_COMPILATION_CONDITIONS=ASTERISM_PERFORMANCE_TESTING \-		$(PIPE_PRETTY)- # Req 5.1's second baseline: Recent publish-to-interactive over the 5,000-Entry-# M4 composed fixture (`seeded-scale-m4`). A physical-device run, like the M2-# target above and for the same reason: the signpost metric is only meaningful-# on the hardware the reader uses.+# M4 composed fixture (`seeded-scale-m4`). A physical-device run, because the+# signpost metric is only meaningful on the hardware the reader uses. # # Since task 34 this target also carries Req 5.3's Recent half: the same # measurement over `seeded-scale-m4-duplicateSiteRows`, the worst tolerated
Packages/AsterismCore/Package.swift Modified +1 / -14
diff --git a/Packages/AsterismCore/Package.swift b/Packages/AsterismCore/Package.swiftindex bf53e0a..759048a 100644--- a/Packages/AsterismCore/Package.swift+++ b/Packages/AsterismCore/Package.swift@@ -11,11 +11,6 @@ let package = Package(     products: [         .library(name: "AsterismCore", targets: ["AsterismCore"]),         .library(name: "ConstellationKit", targets: ["ConstellationKit"]),-        .library(-            name: "AsterismV1MigrationSupport",-            targets: ["AsterismV1MigrationSupport"]-        ),-        .executable(name: "AsterismMigrationTool", targets: ["AsterismMigrationTool"]),         .executable(name: "AsterismStoreTestHelper", targets: ["AsterismStoreTestHelper"]),     ],     targets: [@@ -25,21 +20,13 @@ let package = Package(         // and the share extension can import this without importing the store         // layer (Q30).         .target(name: "ConstellationKit"),-        .target(-            name: "AsterismV1MigrationSupport",-            dependencies: ["AsterismCore"]-        ),-        .executableTarget(-            name: "AsterismMigrationTool",-            dependencies: ["AsterismCore", "AsterismV1MigrationSupport"]-        ),         .executableTarget(             name: "AsterismStoreTestHelper",             dependencies: ["AsterismCore"]         ),         .testTarget(             name: "AsterismCoreTests",-            dependencies: ["AsterismCore", "AsterismV1MigrationSupport"],+            dependencies: ["AsterismCore"],             resources: [.copy("Fixtures")]         ),         .testTarget(
Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift Modified +6 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swiftindex 4c3482a..a67ea33 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift@@ -20,11 +20,12 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable {     public static let m3 = AsterismCapabilities(gate: .m3)     public static let m4 = AsterismCapabilities(gate: .m4) -    /// The current runtime gate is `.m4` (Decision 2, backup 4/4 work). The-    /// frozen `BackupV3Codec` is pinned to the literal `"m3"` and `BackupV4Codec`-    /// to `"m4"`, so each codec stamps its own historical gate independently of-    /// this value. Earlier gates stay available so frozen validators and fixtures-    /// can prove their historical behavior unchanged.+    /// The current runtime gate is `.m4` (Decision 2, backup 4/4 work).+    /// `BackupV4Codec` stamps the literal `"m4"` rather than reading this value,+    /// so the archive's gate is independent of the runtime's. Earlier gates stay+    /// available because the schema and teaching suites still exercise them —+    /// `SchemaV2Tests`, `CapabilityGatingTests`, `PhraseParsingTests`,+    /// `RepositoryTeachingTests`.     public static let current = AsterismCapabilities.m4      public let gate: Gate
Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift Modified +2 / -26
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftindex 6d03f94..aaeece5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -1,30 +1,7 @@ import Foundation-import SwiftData--/// Protocol for providing one coherent backup snapshot. It isolates export from-/// persistence and permits injected failures in unit tests.-public protocol BackupSnapshotProviding: Sendable {-    func backupSnapshot() async throws -> LibraryBackupSnapshot-}--extension LibraryRepository: BackupSnapshotProviding {-    public func backupSnapshot() async throws -> LibraryBackupSnapshot {-        try await withLockedBackupContext { context in-            let entries = try context.fetch(FetchDescriptor<Entry>())-            let works = try context.fetch(FetchDescriptor<Work>())-            let sites = try context.fetch(FetchDescriptor<Site>())-            let patterns = try context.fetch(FetchDescriptor<TitlePattern>())--            return LibraryBackupSnapshot(-                entries: try entries.map(Self.mapEntryRecord),-                works: try works.map(Self.mapWorkRecord),-                sites: try sites.map(Self.mapSiteRecord),-                titlePatterns: try patterns.map(Self.mapTitlePatternRecord)-            )-        }-    }-} +/// The file a completed backup export produced, handed to the share sheet and+/// cleaned up afterwards. `BackupV4Exporter` is the only producer. public struct BackupExportResult: Sendable {     public let fileURL: URL @@ -32,4 +9,3 @@ public struct BackupExportResult: Sendable {         self.fileURL = fileURL     } }-
Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift Modified +27 / -399
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex 4257cce..c0c9456 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -4,26 +4,13 @@ import OSLog // MARK: - Import Plan  /// The immutable import plan built outside the repository actor and without a-/// process lease. Represents a complete validated prospective V3 graph ready to+/// process lease. Represents a complete validated prospective V4 graph ready to /// be materialized atomically.-public struct BackupImportPlan: Sendable, Equatable {-    /// The decoded backup metadata for display.-    public let metadata: BackupImportMetadata-    /// The complete prospective V3 payload, validated and ready to materialize.-    public let payload: BackupV3Payload-    /// Counts derived from the validated plan for inventory comparison.-    public let counts: LibraryRecordCounts--    public init(metadata: BackupImportMetadata, payload: BackupV3Payload, counts: LibraryRecordCounts) {-        self.metadata = metadata-        self.payload = payload-        self.counts = counts-    }-}--/// The V4 analog of `BackupImportPlan` (Req 5.1, 5.2). Every accepted source-/// version — `2/2/m2.3`, `3/3`, and native `4/4` — maps into one validated-/// prospective V4 graph. Built outside the repository actor and without a lease.+///+/// Native `4/4` is the only accepted source version. The `2/2` and `3/3` import+/// paths were retired once every archive worth importing had been re-exported at+/// 4/4; recovering a pre-M3.5 archive now means checking out a build that still+/// carries those codecs. public struct BackupImportV4Plan: Sendable, Equatable {     public let metadata: BackupImportMetadata     public let payload: BackupV4Payload@@ -103,23 +90,21 @@ public enum BackupImportError: Error, Equatable, Sendable, CustomStringConvertib  // MARK: - BackupImporter -/// Reads the strict envelope discriminator and dispatches to the appropriate codec.-/// Builds an immutable `BackupImportPlan` outside the repository actor and without-/// a process lease. Never mutates the selected file.+/// Reads the strict envelope discriminator and dispatches to the V4 codec.+/// Builds an immutable `BackupImportV4Plan` outside the repository actor and+/// without a process lease. Never mutates the selected file. ///-/// Design §5.3: Import supports only exact `2/2/m2.3` via `LegacyBackupV2Codec`-/// or `3/3` via `BackupV3Codec`. Mixed pairs, gates `m2.0`–`m2.2`, malformed/future-/// headers, and V3 fields inserted into V2 reject before repository mutation.+/// Import supports only exact native `4/4`. Mixed pairs and malformed or future+/// headers reject before repository mutation. public enum BackupImporter {     private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImporter")      // MARK: - V4 Plan Dispatch (Req 5.1, 5.2, Decision 2) -    /// Builds a validated prospective V4 import plan from raw backup data. All-    /// accepted source versions map into the composed V4 graph:-    /// `(2,2) → V2→V3→V4` (chaining both mappers), `(3,3) → V3→V4`, `(4,4) →-    /// native`, everything else unsupported. Runs entirely outside the-    /// repository actor and holds no process lease.+    /// Builds a validated prospective V4 import plan from raw backup data.+    /// Native `4/4` is the only accepted source version; everything else is+    /// unsupported. Runs entirely outside the repository actor and holds no+    /// process lease.     public static func planV4(from data: Data) throws -> BackupImportV4Plan {         logger.debug("Building V4 import plan from \(data.count) bytes") @@ -127,71 +112,26 @@ public enum BackupImporter {         logger.debug("Detected format=\(formatVersion) schema=\(schemaVersion)")          switch (formatVersion, schemaVersion) {-        case (2, 2):-            return try planV4FromLegacyV2(data)-        case (3, 3):-            return try planV4FromV3(data)         case (4, 4):             return try planV4FromV4(data)+        case (2, 2), (3, 3):+            // Recognised, and deliberately no longer readable. Say so plainly:+            // these archives were restorable until the older import paths were+            // retired, so a reader meeting this has done nothing wrong.+            throw BackupImportError.unsupportedFormat(+                reason: """+                    this backup is in the older \(formatVersion)/\(schemaVersion) \+                    format, which this version of Asterism can no longer restore. \+                    Only backups exported by a recent version (4/4) can be imported.+                    """+            )         default:             throw BackupImportError.unsupportedFormat(-                reason: "format \(formatVersion)/schema \(schemaVersion) is not supported; expected 2/2, 3/3, or 4/4"+                reason: "format \(formatVersion)/schema \(schemaVersion) is not supported; expected 4/4"             )         }     } -    private static func planV4FromLegacyV2(_ data: Data) throws -> BackupImportV4Plan {-        logger.debug("Decoding legacy Backup V2 for V4 import")-        let document: LegacyBackupV2Document-        do {-            document = try LegacyBackupV2Codec.decode(data)-        } catch {-            throw BackupImportError.decodingFailed(reason: String(describing: error))-        }-        // Chain both mappers: V2 → V3 → V4 (the (2,2) path). The frozen 3/3-        // reference validator guards the intermediate V3 payload (Q18).-        let v3Payload = try V2ToV3BackupMapper.map(document.payload)-        do { try BackupV3ReferenceValidator.validate(payload: v3Payload) }-        catch { throw BackupImportError.validationFailed(reason: "mapped V3 graph failed validation: \(error)") }-        let v4Payload = try V3ToV4BackupMapper.map(v3Payload)-        let counts = try validateV4(v4Payload)-        let metadata = BackupImportMetadata(-            formatVersion: BackupV4Document.formatVersion,-            schemaVersion: BackupV4Document.schemaVersion,-            appBuild: document.appBuild,-            exportedAt: document.exportedAt,-            capabilityGate: BackupV4Codec.gate,-            entryCount: v4Payload.entries.count,-            workCount: v4Payload.works.count-        )-        return BackupImportV4Plan(metadata: metadata, payload: v4Payload, counts: counts)-    }--    private static func planV4FromV3(_ data: Data) throws -> BackupImportV4Plan {-        logger.debug("Decoding Backup V3 for V4 import")-        let document: BackupV3Document-        do {-            document = try BackupV3Codec.decode(data)-        } catch {-            throw BackupImportError.decodingFailed(reason: String(describing: error))-        }-        // The 3/3 import path keeps its own frozen reference validator (Q18).-        do { try BackupV3ReferenceValidator.validate(payload: document.payload) }-        catch { throw BackupImportError.validationFailed(reason: "Backup V3 graph failed validation: \(error)") }-        let v4Payload = try V3ToV4BackupMapper.map(document.payload)-        let counts = try validateV4(v4Payload)-        let metadata = BackupImportMetadata(-            formatVersion: BackupV4Document.formatVersion,-            schemaVersion: BackupV4Document.schemaVersion,-            appBuild: document.appBuild,-            exportedAt: document.exportedAt,-            capabilityGate: BackupV4Codec.gate,-            entryCount: v4Payload.entries.count,-            workCount: v4Payload.works.count-        )-        return BackupImportV4Plan(metadata: metadata, payload: v4Payload, counts: counts)-    }-     private static func planV4FromV4(_ data: Data) throws -> BackupImportV4Plan {         logger.debug("Decoding native Backup V4")         let document: BackupV4Document@@ -244,315 +184,3 @@ public enum BackupImporter {     } } -// MARK: - V2 to V3 Backup Mapper--/// Maps a validated legacy Backup V2 payload to a prospective Backup V3 payload.-///-/// Design §5.3: Preserves every carried value and relationship. Each dormant Site-/// rule becomes an immutable historical `.importedV2` rule. Every nonblank Work-/// identity becomes `legacyUnverified` with no rule reference.-public enum V2ToV3BackupMapper {-    public static func map(_ payload: LegacyBackupV2Payload) throws -> BackupV3Payload {-        var urlRules: [BackupV3URLRule] = []--        // Map sites and their dormant URL rules-        let sites: [BackupV3Site] = payload.sites.map { site in-            var ruleIDs: [UUID] = []--            if let rule = site.urlIdentityRule {-                let ruleID = UUID()-                let locator = mapLocator(rule)-                let definition = URLRuleDefinition.work(locator: locator)-                let v3Rule = BackupV3URLRule(-                    id: ruleID,-                    version: rule.version,-                    isCurrent: false,  // Always historical for imported V2-                    createdAt: Date(timeIntervalSince1970: 0),  // Unix epoch per design-                    origin: .importedV2,-                    definition: definition,-                    siteHostname: site.hostname-                )-                urlRules.append(v3Rule)-                ruleIDs.append(ruleID)-            }--            let titleInterpretation: BackupV3Types.FrozenTitleInterpretation? = switch site.mode {-            case .taught: .pattern-            case .untaught, .articles: nil-            }--            return BackupV3Site(-                hostname: site.hostname,-                displayName: site.displayName,-                mode: site.mode,-                titleInterpretation: titleInterpretation,-                patternIDs: site.patternIDs,-                urlRuleIDs: ruleIDs,-                junkSuffixRule: site.junkSuffixRule-            )-        }--        // Map works: all nonblank urlIdentity → legacyUnverified-        let works: [BackupV3Work] = payload.works.map { work in-            let identityState: WorkURLIdentityState-            if let identity = work.urlIdentity, !M2Unicode.isBlank(identity) {-                identityState = .legacyUnverified-            } else {-                identityState = .none-            }-            return BackupV3Work(-                id: work.id,-                displayTitle: work.displayTitle,-                lastParsedTitle: work.lastParsedTitle,-                siteHostname: work.siteHostname,-                urlIdentity: work.urlIdentity,-                urlIdentityState: identityState,-                urlIdentityRuleID: nil,  // No rule reference for legacy-                urlIdentityRuleVersion: nil,-                workURL: work.workURL,-                genericNotes: work.genericNotes,-                type: work.type,-                genreTags: work.genreTags,-                titleProvenance: work.titleProvenance,-                createdAt: work.createdAt,-                modifiedAt: work.modifiedAt,-                entryIDs: work.entryIDs-            )-        }--        // Map entries: conservative identity only, no URL-derived fields-        let entries: [BackupV3Entry] = payload.entries.map { entry in-            BackupV3Entry(-                id: entry.id,-                captureTitle: entry.captureTitle,-                captureTitleSource: entry.captureTitleSource,-                rawURL: entry.rawURL,-                canonicalURL: entry.canonicalURL,-                hostname: entry.hostname,-                entryIdentityKey: entry.entryIdentityKey,-                identityKeyVersion: entry.identityKeyVersion,-                identityBasis: .conservative,-                identityURLRuleID: nil,-                identityURLRuleVersion: nil,-                urlWorkIdentity: nil,-                urlWorkRuleID: nil,-                urlWorkRuleVersion: nil,-                chapterSequence: nil,-                chapterSequenceRuleID: nil,-                chapterSequenceRuleVersion: nil,-                chapterTitle: entry.chapterTitle,-                chapterTitleProvenance: entry.chapterTitleProvenance,-                note: entry.note,-                rating: entry.rating,-                firstCapturedAt: entry.firstCapturedAt,-                lastSharedAt: entry.lastSharedAt,-                modifiedAt: entry.modifiedAt,-                workID: entry.workID,-                workAssignmentProvenance: entry.workAssignmentProvenance,-                workURLRuleID: nil,-                workURLRuleVersion: nil,-                workURLAssignmentKind: nil,-                workPatternID: entry.workAssignmentProvenance.patternID,-                workPatternVersion: entry.workAssignmentProvenance.patternVersion,-                intentionallyUnattached: entry.intentionallyUnattached-            )-        }--        // Map title patterns-        let titlePatterns: [BackupV3TitlePattern] = payload.titlePatterns.map { pattern in-            BackupV3TitlePattern(-                id: pattern.id,-                version: pattern.version,-                isActive: pattern.isActive,-                createdAt: pattern.createdAt,-                definition: pattern.definition,-                siteHostname: pattern.siteHostname-            )-        }--        return BackupV3Payload(-            entries: entries,-            works: works,-            sites: sites,-            titlePatterns: titlePatterns,-            urlRules: urlRules-        )-    }--    /// Maps a legacy V2 URL identity rule to a V3 URL component locator.-    private static func mapLocator(_ rule: URLIdentityRule) -> URLComponentLocator {-        switch rule.component {-        case .pathSegment:-            // Imported V2 positional path rule-            return .importedV2Path(-                origin: rule.origin ?? .start,-                offset: rule.offset ?? 0-            )-        case .queryItem:-            // Query name rule preserves its exact name-            return .query(name: ExactScalarString(rule.queryName ?? ""))-        }-    }-}--// MARK: - V3 to V4 Backup Mapper--/// Maps a validated Backup V3 payload to a prospective Backup V4 payload,-/// applying the *same mapping as the store migration* (Req 5.1, Decision 2,-/// following the `V2ToV3BackupMapper` precedent):-///-/// - Ordinary taught (`titleInterpretation == .pattern` or the grandfathered-///   `nil`, Req 5.3): retain the existing chapter-bearing title pattern(s).-/// - Work-only taught (`titleInterpretation == .wholeCaptureTitle`): synthesize-///   an active whole-title title rule carrying the Site's `workTitleTrimRule`-///   affixes, and drop the interpretation and site-level trim.-/// - Untaught and articles Sites: carried through unchanged.-///-/// Every carried value and provenance reference is preserved (Entry chapter and-/// assignment provenance, key basis, sequence references), and the conservative--/// key alias is backfilled from the immutable raw URL (Q21). `V2ToV3BackupMapper`-/// is untouched — it never emits `.sequence` — so the `(2,2)` path chains this-/// mapper after it.-public enum V3ToV4BackupMapper {-    /// A deterministic timestamp for the synthesized whole-title pattern, so a-    /// mapped import is reproducible (mirrors the epoch precedent the V2→V3-    /// mapper uses for its synthesized records).-    private static let synthesizedCreatedAt = Date(timeIntervalSince1970: 0)--    public static func map(_ payload: BackupV3Payload) throws -> BackupV4Payload {-        // Carry every existing title pattern through, dropping nothing but the-        // interpretation/trim that lived on the Site.-        var titlePatterns: [BackupV4TitlePattern] = payload.titlePatterns.map { pattern in-            BackupV4TitlePattern(-                id: pattern.id,-                version: pattern.version,-                isActive: pattern.isActive,-                createdAt: pattern.createdAt,-                definition: pattern.definition,-                trimPrefix: nil,-                trimSuffix: nil,-                siteHostname: pattern.siteHostname-            )-        }--        let sites: [BackupV4Site] = payload.sites.map { site in-            var patternIDs = site.patternIDs-            if site.mode == .taught, site.titleInterpretation == .wholeCaptureTitle {-                // Migrated Work-only Site → active whole-title rule carrying the-                // Site's trim affixes (Req 5.1). The Site had no title patterns-                // (M3 forbids them for Work-only), so this becomes the single-                // active rule.-                let patternID = UUID()-                let trim = site.workTitleTrimRule-                titlePatterns.append(BackupV4TitlePattern(-                    id: patternID,-                    version: 1,-                    isActive: true,-                    createdAt: synthesizedCreatedAt,-                    definition: .wholeTitle,-                    trimPrefix: nonEmpty(trim?.prefix),-                    trimSuffix: nonEmpty(trim?.suffix),-                    siteHostname: site.hostname-                ))-                patternIDs.append(patternID)-            }-            return BackupV4Site(-                hostname: site.hostname,-                displayName: site.displayName,-                mode: site.mode,-                patternIDs: patternIDs,-                urlRuleIDs: site.urlRuleIDs,-                junkSuffixRule: site.junkSuffixRule-            )-        }--        let works: [BackupV4Work] = payload.works.map { work in-            BackupV4Work(-                id: work.id,-                displayTitle: work.displayTitle,-                lastParsedTitle: work.lastParsedTitle,-                siteHostname: work.siteHostname,-                urlIdentity: work.urlIdentity,-                urlIdentityState: work.urlIdentityState,-                urlIdentityRuleID: work.urlIdentityRuleID,-                urlIdentityRuleVersion: work.urlIdentityRuleVersion,-                workURL: work.workURL,-                genericNotes: work.genericNotes,-                type: work.type,-                genreTags: work.genreTags,-                titleProvenance: work.titleProvenance,-                createdAt: work.createdAt,-                modifiedAt: work.modifiedAt,-                entryIDs: work.entryIDs-            )-        }--        // Entries carry every V3 field through; the conservative-key alias is-        // backfilled from the immutable raw URL, and no Entry carries a v3-        // sequence+name basis yet (a V3 backup only holds key versions 1–2).-        let entries: [BackupV4Entry] = payload.entries.map { entry in-            BackupV4Entry(-                id: entry.id,-                captureTitle: entry.captureTitle,-                captureTitleSource: entry.captureTitleSource,-                rawURL: entry.rawURL,-                canonicalURL: entry.canonicalURL,-                hostname: entry.hostname,-                entryIdentityKey: entry.entryIdentityKey,-                identityKeyVersion: entry.identityKeyVersion,-                conservativeIdentityKey: entry.rawURL,-                identityBasis: entry.identityBasis,-                identityURLRuleID: entry.identityURLRuleID,-                identityURLRuleVersion: entry.identityURLRuleVersion,-                identityNameTitleRuleID: nil,-                identityNameTitleRuleVersion: nil,-                urlWorkIdentity: entry.urlWorkIdentity,-                urlWorkRuleID: entry.urlWorkRuleID,-                urlWorkRuleVersion: entry.urlWorkRuleVersion,-                chapterSequence: entry.chapterSequence,-                chapterSequenceRuleID: entry.chapterSequenceRuleID,-                chapterSequenceRuleVersion: entry.chapterSequenceRuleVersion,-                chapterTitle: entry.chapterTitle,-                chapterTitleProvenance: entry.chapterTitleProvenance,-                note: entry.note,-                rating: entry.rating,-                firstCapturedAt: entry.firstCapturedAt,-                lastSharedAt: entry.lastSharedAt,-                modifiedAt: entry.modifiedAt,-                workID: entry.workID,-                workAssignmentProvenance: entry.workAssignmentProvenance,-                workURLRuleID: entry.workURLRuleID,-                workURLRuleVersion: entry.workURLRuleVersion,-                workURLAssignmentKind: entry.workURLAssignmentKind,-                workPatternID: entry.workPatternID,-                workPatternVersion: entry.workPatternVersion,-                intentionallyUnattached: entry.intentionallyUnattached-            )-        }--        let urlRules: [BackupV4URLRule] = payload.urlRules.map { rule in-            BackupV4URLRule(-                id: rule.id,-                version: rule.version,-                isCurrent: rule.isCurrent,-                createdAt: rule.createdAt,-                origin: rule.origin,-                definition: rule.definition,-                siteHostname: rule.siteHostname-            )-        }--        return BackupV4Payload(-            entries: entries,-            works: works,-            sites: sites,-            titlePatterns: titlePatterns,-            urlRules: urlRules-        )-    }--    private static func nonEmpty(_ value: String?) -> String? {-        guard let value, !value.isEmpty else { return nil }-        return value-    }-}
Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift Added +167 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swiftnew file mode 100644index 0000000..f71193e--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift@@ -0,0 +1,167 @@+import Foundation++// Extracted from the retired `LegacyBackupV2Codec` when the 2/2 and 3/3 import+// paths were removed. Both helpers are used by the live `BackupV4Codec`: the+// date formatter fixes the archive's timestamp encoding, and the duplicate-key+// validator is what makes a decode strict rather than last-key-wins.++// MARK: - Archive Date Formatter++/// The archive's timestamp encoding: ISO 8601 with fractional seconds, in UTC,+/// quantized to whole milliseconds so a round-trip is exact.+internal enum BackupArchiveDateFormatter {+    static func string(from date: Date) -> String {+        formatter().string(from: MillisecondInstant.quantize(date))+    }++    static func date(from value: String) -> Date? {+        formatter().date(from: value)+    }++    private static func formatter() -> ISO8601DateFormatter {+        let formatter = ISO8601DateFormatter()+        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]+        formatter.timeZone = TimeZone(secondsFromGMT: 0)+        return formatter+    }+}++// MARK: - Duplicate JSON Key Validator++/// Rejects a repeated JSON key instead of silently resolving it. Load-bearing on+/// the live decode path: `BackupV4ShapeValidator` goes through+/// `JSONSerialization`, which collapses duplicates without complaint, so this is+/// the only thing standing between a two-`payload` archive and importing the+/// wrong one. It also enforces no-trailing-bytes. `BackupV4CodecTests` covers+/// both properties by editing encoded bytes directly — they cannot be reached+/// through any `JSONSerialization` round-trip.+internal struct DuplicateJSONKeyValidator {+    private let bytes: [UInt8]+    private var index = 0++    static func validate(_ data: Data) throws {+        var parser = DuplicateJSONKeyValidator(bytes: Array(data))+        try parser.parseValue(path: "$")+        parser.skipWhitespace()+        guard parser.index == parser.bytes.count else {+            throw BackupCodecError.trailingBytes+        }+    }++    private mutating func parseValue(path: String) throws {+        skipWhitespace()+        guard let byte = current else {+            throw BackupCodecError.decodingFailed(reason: "unexpected end of JSON")+        }+        switch byte {+        case 0x7B: try parseObject(path: path)+        case 0x5B: try parseArray(path: path)+        case 0x22: _ = try parseString()+        case 0x74: try consume("true")+        case 0x66: try consume("false")+        case 0x6E: try consume("null")+        case 0x2D, 0x30...0x39: parseNumber()+        default: throw BackupCodecError.decodingFailed(reason: "unexpected JSON token at byte \(index)")+        }+    }++    private mutating func parseObject(path: String) throws {+        index += 1+        skipWhitespace()+        if consumeIf(0x7D) { return }+        var keys: Set<String> = []+        while true {+            skipWhitespace()+            let key = try parseString()+            guard keys.insert(key).inserted else {+                throw BackupCodecError.duplicateKey("\(path).\(key)")+            }+            skipWhitespace()+            try require(0x3A)+            try parseValue(path: "\(path).\(key)")+            skipWhitespace()+            if consumeIf(0x7D) { return }+            try require(0x2C)+        }+    }++    private mutating func parseArray(path: String) throws {+        index += 1+        skipWhitespace()+        if consumeIf(0x5D) { return }+        var element = 0+        while true {+            try parseValue(path: "\(path)[\(element)]")+            element += 1+            skipWhitespace()+            if consumeIf(0x5D) { return }+            try require(0x2C)+        }+    }++    private mutating func parseString() throws -> String {+        guard current == 0x22 else {+            throw BackupCodecError.decodingFailed(reason: "expected JSON string at byte \(index)")+        }+        let start = index+        index += 1+        var escaped = false+        while let byte = current {+            index += 1+            if escaped {+                escaped = false+            } else if byte == 0x5C {+                escaped = true+            } else if byte == 0x22 {+                let slice = Data(bytes[start..<index])+                do { return try JSONDecoder().decode(String.self, from: slice) }+                catch {+                    throw BackupCodecError.decodingFailed(reason: "invalid JSON string at byte \(start)")+                }+            } else if byte < 0x20 {+                throw BackupCodecError.decodingFailed(reason: "unescaped control scalar in JSON string")+            }+        }+        throw BackupCodecError.decodingFailed(reason: "unterminated JSON string")+    }++    private mutating func parseNumber() {+        while let byte = current,+              byte == 0x2D || byte == 0x2B || byte == 0x2E ||+              byte == 0x45 || byte == 0x65 || (0x30...0x39).contains(byte) {+            index += 1+        }+    }++    private mutating func consume(_ literal: StaticString) throws {+        let expected = Array(String(describing: literal).utf8)+        guard index + expected.count <= bytes.count,+              Array(bytes[index..<(index + expected.count)]) == expected else {+            throw BackupCodecError.decodingFailed(reason: "invalid JSON literal at byte \(index)")+        }+        index += expected.count+    }++    private mutating func require(_ byte: UInt8) throws {+        skipWhitespace()+        guard consumeIf(byte) else {+            throw BackupCodecError.decodingFailed(reason: "missing JSON punctuation at byte \(index)")+        }+    }++    private mutating func consumeIf(_ byte: UInt8) -> Bool {+        guard current == byte else { return false }+        index += 1+        return true+    }++    private mutating func skipWhitespace() {+        while let byte = current, byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D {+            index += 1+        }+    }++    private var current: UInt8? {+        index < bytes.count ? bytes[index] : nil+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift Modified +15 / -77
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swiftindex 2bffc4f..03e8905 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV2Types.swift@@ -209,51 +209,15 @@ public struct LibraryBackupSnapshot: Codable, Equatable, Sendable {     public static let empty = LibraryBackupSnapshot(entries: [], works: [], sites: [], titlePatterns: []) } -// MARK: - Envelope--public struct BackupMetadata: Codable, Equatable, Sendable {-    public let appBuild: String-    public let databaseSchemaVersion: Int-    public let exportedAt: Date--    public init(appBuild: String, databaseSchemaVersion: Int, exportedAt: Date) {-        self.appBuild = appBuild-        self.databaseSchemaVersion = databaseSchemaVersion-        self.exportedAt = exportedAt-    }-}--public struct BackupV2Document: Codable, Equatable, Sendable {-    public static let formatVersion = 2-    public static let schemaVersion = 2--    public let backupFormatVersion: Int-    public let databaseSchemaVersion: Int-    public let appBuild: String-    public let exportedAt: Date-    public let capabilityGate: AsterismCapabilities.Gate-    public let payload: LibraryBackupSnapshot--    public init(-        metadata: BackupMetadata,-        capabilities: AsterismCapabilities,-        payload: LibraryBackupSnapshot-    ) {-        backupFormatVersion = Self.formatVersion-        databaseSchemaVersion = metadata.databaseSchemaVersion-        appBuild = metadata.appBuild-        exportedAt = metadata.exportedAt-        capabilityGate = capabilities.gate-        self.payload = payload-    }-}-+/// Structural faults in an archive's JSON, raised by `DuplicateJSONKeyValidator`+/// and surfaced through `BackupImportError.decodingFailed` — which reaches the+/// reader on the import screen, so the wording is archive-generic rather than+/// naming a format version. The four cases the retired format-2 codec threw+/// (`encodingFailed`, `invalidFormatVersion`, `invalidSchemaVersion`,+/// `capabilityMismatch`) went with it; `BackupV4CodecError` carries the live+/// equivalents. public enum BackupCodecError: Error, Equatable, Sendable, CustomStringConvertible {-    case encodingFailed(reason: String)     case decodingFailed(reason: String)-    case invalidFormatVersion(Int)-    case invalidSchemaVersion(Int)-    case capabilityMismatch(expected: AsterismCapabilities.Gate, actual: AsterismCapabilities.Gate)     case unknownKey(String)     case duplicateKey(String)     case missingKey(String)@@ -262,17 +226,12 @@ public enum BackupCodecError: Error, Equatable, Sendable, CustomStringConvertibl      public var description: String {         switch self {-        case .encodingFailed(let reason): "Backup V2 encoding failed: \(reason)"-        case .decodingFailed(let reason): "Backup V2 decoding failed: \(reason)"-        case .invalidFormatVersion(let value): "Unsupported backup format version: \(value)"-        case .invalidSchemaVersion(let value): "Unsupported database schema version: \(value)"-        case .capabilityMismatch(let expected, let actual):-            "Backup capability gate \(actual.rawValue) does not match \(expected.rawValue)"-        case .unknownKey(let key): "Unknown key in Backup V2: \(key)"-        case .duplicateKey(let key): "Duplicate key in Backup V2: \(key)"-        case .missingKey(let key): "Missing required Backup V2 key: \(key)"-        case .invalidValue(let key, let reason): "Invalid Backup V2 value for \(key): \(reason)"-        case .trailingBytes: "Trailing bytes after Backup V2 document"+        case .decodingFailed(let reason): "Backup decoding failed: \(reason)"+        case .unknownKey(let key): "Unknown key in the backup: \(key)"+        case .duplicateKey(let key): "Duplicate key in the backup: \(key)"+        case .missingKey(let key): "Missing required backup key: \(key)"+        case .invalidValue(let key, let reason): "Invalid backup value for \(key): \(reason)"+        case .trailingBytes: "Trailing bytes after the backup document"         }     } }@@ -303,29 +262,8 @@ public enum BackupValidationError: Error, Equatable, Sendable, CustomStringConve         case .invalidPatternDefinition(let id, let reason):             "TitlePattern \(id) has an invalid definition: \(reason)"         case .invalidStateTuple(let type, let id, let reason):-            "\(type) \(id) has an invalid V2 state: \(reason)"-        case .snapshotMismatch(let reason): "Backup V2 snapshot mismatch: \(reason)"+            "\(type) \(id) has an invalid state: \(reason)"+        case .snapshotMismatch(let reason): "Library snapshot mismatch: \(reason)"         }     } }--public enum BackupV2Codec {-    public static func encode(-        snapshot: LibraryBackupSnapshot,-        metadata: BackupMetadata,-        capabilities: AsterismCapabilities-    ) throws -> Data {-        try BackupV2CodecImplementation.encode(-            snapshot: snapshot,-            metadata: metadata,-            capabilities: capabilities-        )-    }--    public static func decode(-        _ data: Data,-        capabilities: AsterismCapabilities-    ) throws -> BackupV2Document {-        try BackupV2CodecImplementation.decode(data, capabilities: capabilities)-    }-}
Packages/AsterismCore/Sources/AsterismCore/BackupV4Codec.swift Modified +13 / -10
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Codec.swiftindex 5af02cb..556d7e5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Codec.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Codec.swift@@ -1,10 +1,12 @@ import Foundation import CryptoKit -/// Strict Backup V4 codec (Req 5.2, Decision 2). Clones the frozen V3 codec's-/// shape: canonical JSON, a SHA-256 checksum over the payload bytes, entry/work-/// counts, root-strict envelope validation, typed nested decode, and a-/// reference validator enforcing the closed tuple and Entry-state tables.+/// Strict Backup V4 codec (Req 5.2, Decision 2), and the only archive codec the+/// app has: canonical JSON, a SHA-256 checksum over the payload bytes,+/// entry/work counts, root-strict envelope validation, typed nested decode, and+/// a reference validator enforcing the closed tuple and Entry-state tables. The+/// shape was inherited from the format-3 codec, which has since been retired+/// along with the older import paths. /// /// The capability gate is pinned to the literal `"m4"`. A V4 payload is frozen /// the moment it ships; the codec must never stamp or accept a later gate even@@ -113,13 +115,13 @@ public enum BackupV4Codec {      private static func encodeV4Date(_ date: Date, encoder: Encoder) throws {         var container = encoder.singleValueContainer()-        try container.encode(LegacyV2DateFormatter.string(from: date))+        try container.encode(BackupArchiveDateFormatter.string(from: date))     }      private static func decodeV4Date(_ decoder: Decoder) throws -> Date {         let container = try decoder.singleValueContainer()         let value = try container.decode(String.self)-        guard let date = LegacyV2DateFormatter.date(from: value) else {+        guard let date = BackupArchiveDateFormatter.date(from: value) else {             throw DecodingError.dataCorruptedError(                 in: container,                 debugDescription: "date must be RFC 3339 UTC with milliseconds"@@ -175,10 +177,11 @@ public struct BackupV4Metadata: Sendable {  // MARK: - V4 Shape Validator -/// Root-strict shape validation (same strictness profile as V3): the envelope-/// root must carry exactly the required keys; deeper shape is enforced by typed-/// decoding and the reference validator. Deep per-key strictness exists only on-/// the frozen legacy V2 codec.+/// Root-strict shape validation: the envelope root must carry exactly the+/// required keys; deeper shape is enforced by typed decoding and the reference+/// validator. Note this runs through `JSONSerialization`, which resolves a+/// duplicate key silently — `DuplicateJSONKeyValidator` is what rejects one, and+/// `decode` must keep running it first. internal enum BackupV4ShapeValidator {     static func validate(_ data: Data) throws {         let object = try JSONSerialization.jsonObject(with: data)
Packages/AsterismCore/Sources/AsterismCore/BackupV4Types.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Types.swiftindex 4ce2ac8..5f9a423 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Types.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Types.swift@@ -3,10 +3,10 @@ import Foundation // MARK: - Backup V4 Document  /// The V4 backup envelope. Format version 4, schema version 4 (Req 5.2,-/// Decision 2). Clones the frozen V3 envelope shape — entry/work counts plus a-/// checksum over the canonical payload bytes — and carries the composed rule-/// set. Frozen formats are never redefined in place, so this is a new type-/// rather than an edit to `BackupV3Document`.+/// Decision 2). Carries the format-3 envelope shape — entry/work counts plus a+/// checksum over the canonical payload bytes — plus the composed rule set. It+/// was added as a new type rather than an edit to the format-3 document because+/// a shipped archive format is never redefined in place. public struct BackupV4Document: Codable, Equatable, Sendable {     public static let formatVersion = 4     public static let schemaVersion = 4
Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift Modified +2 / -7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swiftindex d3b038c..0b6fda7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryConfiguration.swift@@ -3,7 +3,6 @@ import Foundation /// Immutable paths and operational timeouts injected into the repository. public struct LibraryConfiguration: Sendable, Equatable {     public static let storeRelativePath = "Library/Application Support/AsterismV2.sqlite"-    public static let legacyStoreRelativePath = "Library/Application Support/Asterism.sqlite"     public static let lockFilename = "Asterism.lock"     public static let markerFilename = "AsterismV2.ready" @@ -44,7 +43,7 @@ public struct LibraryConfiguration: Sendable, Equatable {     /// the app reads the value its own bundle declares and hands it in, so the     /// package holds no identifier literal for the lint to find. nil is the     /// default and the only value every non-app caller can produce — host-    /// tests, UI-test temporary roots, the migration helpers and the share+    /// tests, UI-test temporary roots, the store test helper and the share     /// extension all open with mirroring off because none of them has a bundle     /// carrying the key.     public let cloudKitContainerID: String?@@ -58,10 +57,6 @@ public struct LibraryConfiguration: Sendable, Equatable {         rootDirectory.appending(path: Self.storeRelativePath)     } -    public var legacyStoreURL: URL {-        rootDirectory.appending(path: Self.legacyStoreRelativePath)-    }-     public var lockURL: URL {         rootDirectory.appending(path: Self.lockFilename)     }@@ -177,7 +172,7 @@ public extension LibraryConfiguration {     /// Deliberately unlike the identifier readers: it answers rather than     /// throws. Absent, empty, unexpanded, or unrecognised all read as *off*,     /// because "off" is the state every process that cannot mirror is already-    /// in — host tests, UI-test roots, the migration helpers and the share+    /// in — host tests, UI-test roots, the store test helper and the share     /// extension carry no such key at all — and because a mistake here must     /// degrade sync, never the library (Q44). Only an explicitly affirmative     /// declaration turns mirroring on.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +3 / -9
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex b53889c..3cb2c9c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -816,7 +816,6 @@ public actor LibraryRepository {          var markerExists = fileManager.fileExists(atPath: configuration.markerURL.path)         let storeExists = fileManager.fileExists(atPath: configuration.storeURL.path)-        let legacyExists = fileManager.fileExists(atPath: configuration.legacyStoreURL.path)          guard !(markerExists && !storeExists) else {             logger.error("Refusing to replace a missing ready V2 library")@@ -833,12 +832,6 @@ public actor LibraryRepository {                     reason: "the containing app has not initialized the current library"                 )             }-        } else if !storeExists, legacyExists {-            logger.error("Preserving legacy V1 store for explicit developer migration")-            throw LibraryRepositoryError.libraryUnavailable(-                operation: "creating V2 library",-                reason: "a legacy V1 store exists; run make migrate-m1-to-m2 first"-            )         }          let lease = try await CrossProcessLibraryLock.acquire(@@ -1824,8 +1817,9 @@ public actor LibraryRepository {             throw AsterismCapabilityError.articlesUnavailable(gate: capabilities.gate)         } -        // Full closed-tuple and graph validation is shared with Backup V2. The-        // mapper validates raw enum/provenance fields before building records.+        // Full closed-tuple and graph validation, via the snapshot representation+        // `V2LibraryValidator` reads. The mapper validates raw enum/provenance+        // fields before building records.         let snapshot = LibraryBackupSnapshot(             entries: try entries.map(mapEntryRecord),             works: try works.map(mapWorkRecord),
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +5 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex 06a198e..7ab0b3a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -198,8 +198,11 @@ public final class Site {     /// convenience accessor (Q17).     @Relationship(deleteRule: .nullify, inverse: \Work.site)     var works: [Work]?-    /// Frozen V2 field retained only until strict legacy mapping moves it into-    /// historical URLRulePattern records.+    /// A format-2 era column that is now permanently nil: `V4LibraryValidator`+    /// 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.     public var urlIdentityRule: URLIdentityRule?     public var junkSuffixRule: JunkSuffixRule? 
Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift Modified +6 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift b/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swiftindex 0545420..f4e8b90 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V2LibraryValidator.swift@@ -1,8 +1,11 @@ import Foundation -/// One integrity validator shared by live opening, Backup V2 decoding, export,-/// and migration verification. It rejects malformed closed tuples rather than-/// repairing or normalizing persisted state.+/// The closed-tuple integrity validator. Its remaining caller is+/// `LibraryRepository.validateStore`, on the test-store open path — the Backup+/// V2 decode, export, and migration-verification callers it was also written for+/// have all been retired. It rejects malformed closed tuples rather than+/// repairing or normalizing persisted state. The live store's validator is+/// `V4LibraryValidator`. public enum V2LibraryValidator {     public static func validate(         snapshot: LibraryBackupSnapshot,
Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift Modified +11 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift b/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swiftindex 82828e2..fe10c1d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/V4Migration.swift@@ -1,6 +1,16 @@ import Foundation import SwiftData +/// How the frozen `AsterismSchemaV3.Site.titleInterpretationRaw` column encodes+/// the M3 title interpretation. The live `SiteTitleInterpretation` was removed in+/// schema V4 along with its Site columns, so the migration keeps its own copy of+/// the raw values it has to read. Previously lived in `BackupV3Types`, which the+/// retired 3/3 archive format owned.+internal enum FrozenV3TitleInterpretation: String, CaseIterable, Codable, Sendable {+    case pattern+    case wholeCaptureTitle+}+ /// The V3 → V4 data transformation (Decision 3), run by the app bootstrap under /// the exclusive lock — never by a SwiftData custom stage (which does not fire /// between the shared schemas and would also run inside the share extension,@@ -41,7 +51,7 @@ public enum V4Migration {         let taught: [MigrationSidecar.TaughtSite] = sites.compactMap { site in             guard site.modeRaw == SiteMode.taught.rawValue else { return nil }             let interpretation = site.titleInterpretationRaw-                .flatMap(BackupV3Types.FrozenTitleInterpretation.init(rawValue:))+                .flatMap(FrozenV3TitleInterpretation.init(rawValue:))             switch interpretation {             case .wholeCaptureTitle:                 let trim = site.workTitleTrimRule
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift Modified +41 / -34
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 7480305..36010f4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -237,10 +237,14 @@ struct BackupImportTransactionTests {         #expect(counts.titlePatterns == 1)     } -    @Test("BackupImporter rejects format/schema pairs other than 2/2 or 3/3")+    // The envelope key is `backupFormatVersion`, not `formatVersion` — these two+    // used to spell it the second way, which meant they only ever reached the+    // missing-key branch and never the version-pair logic they name.++    @Test("BackupImporter rejects a format/schema pair other than 4/4")     func importerRejectsUnsupportedFormats() {         let data = try! JSONSerialization.data(-            withJSONObject: ["formatVersion": 9, "databaseSchemaVersion": 9],+            withJSONObject: ["backupFormatVersion": 9, "databaseSchemaVersion": 9],             options: []         )         #expect(throws: BackupImportError.self) {@@ -251,7 +255,7 @@ struct BackupImportTransactionTests {     @Test("BackupImporter rejects mixed format/schema (e.g. 2/3)")     func importerRejectsMixedPairs() {         let data = try! JSONSerialization.data(-            withJSONObject: ["formatVersion": 2, "databaseSchemaVersion": 3],+            withJSONObject: ["backupFormatVersion": 2, "databaseSchemaVersion": 3],             options: []         )         #expect(throws: BackupImportError.self) {@@ -455,20 +459,27 @@ private func makeMinimalImportPlan(     let entryID = UUID()     let patternID = UUID()     let urlRuleID = UUID()+    let epoch = Date(timeIntervalSince1970: 1000000)+    let rawURL = "https://imported.example.com/chapter/1"+    let patternProvenance = try FieldProvenance(+        kind: .pattern, patternID: patternID, patternVersion: 1) -    let entry = BackupV3Entry(+    let entry = BackupV4Entry(         id: entryID,         captureTitle: "Imported Chapter",         captureTitleSource: .networkFetch,-        rawURL: "https://imported.example.com/chapter/1",+        rawURL: rawURL,         canonicalURL: nil,         hostname: siteHostname,         // V4 conservative (v1) entries carry the raw URL as their identity key.-        entryIdentityKey: "https://imported.example.com/chapter/1",+        entryIdentityKey: rawURL,         identityKeyVersion: 1,+        conservativeIdentityKey: rawURL,         identityBasis: .conservative,         identityURLRuleID: nil,         identityURLRuleVersion: nil,+        identityNameTitleRuleID: nil,+        identityNameTitleRuleVersion: nil,         urlWorkIdentity: nil,         urlWorkRuleID: nil,         urlWorkRuleVersion: nil,@@ -476,14 +487,14 @@ private func makeMinimalImportPlan(         chapterSequenceRuleID: nil,         chapterSequenceRuleVersion: nil,         chapterTitle: "Chapter 1",-        chapterTitleProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+        chapterTitleProvenance: patternProvenance,         note: "Great chapter",         rating: .up,-        firstCapturedAt: Date(timeIntervalSince1970: 1000000),-        lastSharedAt: Date(timeIntervalSince1970: 1000000),-        modifiedAt: Date(timeIntervalSince1970: 1000000),+        firstCapturedAt: epoch,+        lastSharedAt: epoch,+        modifiedAt: epoch,         workID: workID,-        workAssignmentProvenance: try FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 1),+        workAssignmentProvenance: patternProvenance,         workURLRuleID: nil,         workURLRuleVersion: nil,         workURLAssignmentKind: nil,@@ -492,7 +503,7 @@ private func makeMinimalImportPlan(         intentionallyUnattached: false     ) -    let work = BackupV3Work(+    let work = BackupV4Work(         id: workID,         displayTitle: "Imported Work",         lastParsedTitle: "Imported Work",@@ -506,29 +517,31 @@ private func makeMinimalImportPlan(         type: .novel,         genreTags: ["fantasy"],         titleProvenance: .parsed,-        createdAt: Date(timeIntervalSince1970: 1000000),-        modifiedAt: Date(timeIntervalSince1970: 1000000),+        createdAt: epoch,+        modifiedAt: epoch,         entryIDs: [entryID]     ) -    let pattern = BackupV3TitlePattern(+    let pattern = BackupV4TitlePattern(         id: patternID,         version: 1,         isActive: true,-        createdAt: Date(timeIntervalSince1970: 1000000),+        createdAt: epoch,         definition: .segment(             work: try SegmentRangeSpec(origin: .start, offset: 0, length: 1),             ignored: []         ),+        trimPrefix: nil,+        trimSuffix: nil,         siteHostname: siteHostname     ) -    let urlRules: [BackupV3URLRule] = includeURLRule ? [-        BackupV3URLRule(+    let urlRules: [BackupV4URLRule] = includeURLRule ? [+        BackupV4URLRule(             id: urlRuleID,             version: 1,             isCurrent: true,-            createdAt: Date(timeIntervalSince1970: 1000000),+            createdAt: epoch,             origin: .readerTaught,             definition: .work(                 locator: .pathBracketed(@@ -540,38 +553,32 @@ private func makeMinimalImportPlan(         )     ] : [] -    let site = BackupV3Site(+    let site = BackupV4Site(         hostname: siteHostname,         displayName: siteHostname,         mode: .taught,-        titleInterpretation: .pattern,         patternIDs: [patternID],         urlRuleIDs: urlRules.map(\.id),         junkSuffixRule: nil     ) -    let payload = BackupV3Payload(-        entries: [entry],-        works: [work],-        sites: [site],-        titlePatterns: [pattern],-        urlRules: urlRules-    )-     let metadata = BackupImportMetadata(         formatVersion: 4,         schemaVersion: 4,         appBuild: "test-1.0",-        exportedAt: Date(timeIntervalSince1970: 1000000),+        exportedAt: epoch,         capabilityGate: "m4",         entryCount: 1,         workCount: 1     ) -    // The runtime import commit is V4: map the minimal V3 payload through the-    // V3→V4 mapper (an ordinary `.pattern` Site keeps its chapter-bearing pattern,-    // so the counts are unchanged).-    var v4Payload = try V3ToV4BackupMapper.map(payload)+    var v4Payload = BackupV4Payload(+        entries: [entry],+        works: [work],+        sites: [site],+        titlePatterns: [pattern],+        urlRules: urlRules+    )     switch incoherence {     case .duplicateApplicationUUID:         v4Payload = BackupV4Payload(
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swift Added +65 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swiftindex bf97e0b..c146b43 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4CodecTests.swift@@ -136,6 +136,71 @@ struct BackupV4CodecTests {      // MARK: - Helpers +    // MARK: - Raw-byte strictness+    //+    // These three cannot go through `mutatingRoot`: it round-trips the document+    // through `JSONSerialization`, which collapses a duplicate key to the last+    // one and cannot represent trailing bytes at all. They edit the encoded+    // bytes directly, which is the only way to reach `DuplicateJSONKeyValidator`+    // — the sole guard for both properties on the live decode path+    // (`BackupV4Codec.decode`). They previously lived in the format-2 and+    // format-3 codec suites; those went with the old import paths, and without+    // this port a stubbed validator would pass every remaining backup test while+    // decoding silently reverted to last-key-wins.++    @Test("A duplicate root key is rejected rather than resolved last-wins")+    func duplicateRootKeyRejected() throws {+        let encoded = try encodedMinimal()+        let text = try #require(String(data: encoded, encoding: .utf8))+        #expect(text.hasPrefix("{"))+        // Inject a second `appBuild` ahead of the original.+        let duplicated = try #require(("{\"appBuild\":\"shadow\"," + text.dropFirst()).data(using: .utf8))++        #expect(throws: BackupCodecError.self) { try BackupV4Codec.decode(duplicated) }+    }++    @Test("A duplicate nested key is rejected")+    func duplicateNestedKeyRejected() throws {+        let encoded = try encodedMinimal()+        let text = try #require(String(data: encoded, encoding: .utf8))+        let marker = "\"payload\":{"+        let range = try #require(text.range(of: marker))+        let injected = text.replacingCharacters(+            in: range, with: marker + "\"entries\":[],")++        #expect(throws: BackupCodecError.self) {+            try BackupV4Codec.decode(try #require(injected.data(using: .utf8)))+        }+    }++    @Test("Trailing bytes after the document are rejected")+    func trailingBytesRejected() throws {+        let encoded = try encodedMinimal()+        var withTrailer = encoded+        withTrailer.append(contentsOf: Array("}".utf8))++        #expect(throws: BackupCodecError.self) { try BackupV4Codec.decode(withTrailer) }+    }++    // MARK: - Timestamps++    /// The archive's date format carries fractional seconds and quantizes to+    /// whole milliseconds. Every other test here uses a whole-second timestamp,+    /// so without this one a dropped `.withFractionalSeconds` would round-trip+    /// cleanly and only fail by luck elsewhere.+    @Test("A sub-second timestamp survives the round-trip to the millisecond")+    func fractionalSecondsRoundTrip() throws {+        let fractional = Date(timeIntervalSince1970: 1_721_000_000.123)+        let encoded = try BackupV4Codec.encode(+            payload: BackupV4Fixtures.minimalTaughtPayload(),+            metadata: BackupV4Metadata(appBuild: "fractional", exportedAt: fractional))++        let decoded = try BackupV4Codec.decode(encoded)++        #expect(abs(decoded.exportedAt.timeIntervalSince(fractional)) < 0.001)+        #expect(decoded.exportedAt != Date(timeIntervalSince1970: 1_721_000_000))+    }+     private func encodedMinimal() throws -> Data {         try BackupV4Codec.encode(             payload: BackupV4Fixtures.minimalTaughtPayload(),
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ImportMatrixTests.swift Modified +22 / -178
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ImportMatrixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ImportMatrixTests.swiftindex fb70726..c35727c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ImportMatrixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV4ImportMatrixTests.swift@@ -4,78 +4,14 @@ import Testing  @testable import AsterismCore -/// The full backup import matrix (Req 5.1, 5.2, Decision 2): every accepted-/// source version maps into one validated V4 plan, and mixed/unknown pairs-/// reject before any repository mutation.-@Suite("Backup V4 import matrix")+/// Backup import acceptance (Req 5.1, 5.2, Decision 2): native `4/4` plans and+/// commits, and every other format/schema pair — including the `2/2` and `3/3`+/// this once accepted — rejects before any repository mutation. It was a matrix+/// across three source versions until the older import paths were retired.+@Suite("Backup V4 import acceptance") struct BackupV4ImportMatrixTests {     private let created = Date(timeIntervalSince1970: 1_000_000) -    // MARK: - 2/2/m2.3 chained through both mappers--    @Test("A 2/2/m2.3 backup imports as V4 through both mappers, backfilling aliases")-    func legacyV2ChainsToV4() throws {-        let data = try Data(contentsOf: v2FixtureURL)-        let plan = try BackupImporter.planV4(from: data)--        #expect(plan.metadata.formatVersion == 4)-        #expect(plan.metadata.schemaVersion == 4)-        #expect(plan.metadata.capabilityGate == "m4")-        #expect(plan.payload.entries.count == 1)-        // Conservative alias backfilled from the immutable raw URL (Q21).-        let entry = plan.payload.entries[0]-        #expect(entry.conservativeIdentityKey == entry.rawURL)-        #expect(entry.identityBasis == .conservative)-        // The dormant V2 rule survives as an importedV2 URL-rule reference.-        let site = try #require(plan.payload.sites.first { $0.hostname == "example.com" })-        #expect(site.urlRuleIDs.count == 1)-        let rule = try #require(plan.payload.urlRules.first { $0.id == site.urlRuleIDs[0] })-        #expect(rule.origin == .importedV2)-        #expect(!rule.isCurrent)-    }--    // MARK: - 3/3 carrying titleInterpretation / workTitleTrimRule--    @Test("A 3/3 Work-only Site maps to an active whole-title rule carrying its trims")-    func v3WorkOnlyMapsToWholeTitle() throws {-        let data = try BackupV3Codec.encode(-            payload: workOnlyV3Payload(),-            metadata: BackupV3Metadata(appBuild: "v3", exportedAt: created))-        let plan = try BackupImporter.planV4(from: data)--        #expect(plan.metadata.formatVersion == 4)-        // The Site now references exactly one active whole-title rule.-        let site = try #require(plan.payload.sites.first)-        #expect(site.patternIDs.count == 1)-        let pattern = try #require(plan.payload.titlePatterns.first { $0.id == site.patternIDs[0] })-        #expect(try pattern.definition == .wholeTitle)-        #expect(pattern.isActive)-        #expect(pattern.trimPrefix == "TtH • Story • ")-        #expect(pattern.trimSuffix == nil)-        // The alias is backfilled and the entry provenance is preserved.-        #expect(plan.payload.entries[0].conservativeIdentityKey == plan.payload.entries[0].rawURL)-    }--    @Test("A 3/3 ordinary taught Site retains its chapter-bearing pattern and provenance")-    func v3OrdinaryRetainsPattern() throws {-        let (payload, patternID) = ordinaryV3Payload()-        let data = try BackupV3Codec.encode(-            payload: payload, metadata: BackupV3Metadata(appBuild: "v3", exportedAt: created))-        let plan = try BackupImporter.planV4(from: data)--        // The original pattern is retained (no synthesized whole-title rule).-        #expect(plan.payload.titlePatterns.count == 1)-        #expect(plan.payload.titlePatterns[0].id == patternID)-        if case .segment = try plan.payload.titlePatterns[0].definition {} else {-            Issue.record("expected the retained segment pattern")-        }-        // Chapter and assignment provenance references are preserved.-        let entry = plan.payload.entries[0]-        #expect(entry.chapterTitleProvenance.patternID == patternID)-        #expect(entry.workAssignmentProvenance.patternID == patternID)-        #expect(entry.conservativeIdentityKey == entry.rawURL)-    }-     // MARK: - Native 4/4      @Test("A native 4/4 backup imports without remapping")@@ -94,7 +30,9 @@ struct BackupV4ImportMatrixTests {      @Test("Mixed and unknown format/schema pairs are rejected as unsupported")     func mixedAndUnknownRejected() throws {-        for (format, schema) in [(2, 3), (3, 4), (4, 3), (5, 5), (3, 2)] {+        // (2, 2) and (3, 3) were accepted until the old-format import paths were+        // retired; they now reject like any other unsupported pair.+        for (format, schema) in [(2, 2), (3, 3), (2, 3), (3, 4), (4, 3), (5, 5), (3, 2)] {             let data = try JSONSerialization.data(withJSONObject: [                 "backupFormatVersion": format,                 "databaseSchemaVersion": schema,@@ -119,30 +57,24 @@ struct BackupV4ImportMatrixTests {      // MARK: - The upsert matrix (Req 4.1, 4.2, Decision 8) -    /// Every commit through 2/2, 3/3, and 4/4 reaches the *same* upsert (Req 4.6):-    /// the frozen mappers produce a V4 payload and the single confirm path applies-    /// it. Asserted by committing each one into an empty library and finding the-    /// records there.-    @Test("2/2, 3/3, and 4/4 archives all commit through the one upsert")-    func everyFormatCommitsThroughTheSameUpsert() async throws {-        let legacy = try BackupImporter.planV4(from: try Data(contentsOf: v2FixtureURL))-        let v3 = try BackupImporter.planV4(from: try BackupV3Codec.encode(-            payload: ordinaryV3Payload().0,-            metadata: BackupV3Metadata(appBuild: "v3", exportedAt: created)))-        let v4 = try BackupImporter.planV4(from: try BackupV4Codec.encode(+    /// A planned archive commits through the one upsert path (Req 4.6). This+    /// used to run 2/2, 3/3 and 4/4 through it to assert the frozen mappers all+    /// converged on the same commit; native 4/4 is now the only accepted source,+    /// so what is left to assert is that planning and committing agree on counts.+    @Test("A planned 4/4 archive commits through the upsert")+    func nativeFormatCommitsThroughTheUpsert() async throws {+        let plan = try BackupImporter.planV4(from: try BackupV4Codec.encode(             payload: BackupV4Fixtures.composedPayload(),             metadata: BackupV4Metadata(appBuild: "v4", exportedAt: created))) -        for plan in [legacy, v3, v4] {-            let library = try await UpsertLibrary()-            let result = try await library.repository.confirmImport(plan: plan)-            guard case .committed(let counts) = result else {-                Issue.record("expected committed, got \(result)")-                continue-            }-            #expect(counts.entries == plan.payload.entries.count)-            #expect(counts.sites == plan.payload.sites.count)+        let library = try await UpsertLibrary()+        let result = try await library.repository.confirmImport(plan: plan)+        guard case .committed(let counts) = result else {+            Issue.record("expected committed, got \(result)")+            return         }+        #expect(counts.entries == plan.payload.entries.count)+        #expect(counts.sites == plan.payload.sites.count)     }      @Test("An archive naming records the library lacks adds them all")@@ -282,94 +214,6 @@ struct BackupV4ImportMatrixTests {                 urlRulePatterns: 0))     } -    // MARK: - V3 payload builders--    private func workOnlyV3Payload() -> BackupV3Payload {-        let host = "tthfanfic.org"-        let workID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!-        let entryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!-        let rawURL = "https://tthfanfic.org/Story-28614-94/slug.htm"--        let site = BackupV3Site(-            hostname: host, displayName: "TtH", mode: .taught,-            titleInterpretation: .wholeCaptureTitle,-            patternIDs: [], urlRuleIDs: [], junkSuffixRule: nil,-            workTitleTrimRule: BackupV3Types.FrozenWorkTitleTrimRule(prefix: "TtH • Story • ", suffix: ""))--        let work = BackupV3Work(-            id: workID, displayTitle: "Actual Title", lastParsedTitle: "Actual Title",-            siteHostname: host, urlIdentity: nil, urlIdentityState: .none,-            urlIdentityRuleID: nil, urlIdentityRuleVersion: nil, workURL: nil,-            genericNotes: "", type: .novel, genreTags: [], titleProvenance: .parsed,-            createdAt: created, modifiedAt: created, entryIDs: [entryID])--        let entry = BackupV3Entry(-            id: entryID, captureTitle: "TtH • Story • Actual Title", captureTitleSource: .host,-            rawURL: rawURL, canonicalURL: nil, hostname: host,-            entryIdentityKey: rawURL, identityKeyVersion: 1, identityBasis: .conservative,-            identityURLRuleID: nil, identityURLRuleVersion: nil,-            urlWorkIdentity: nil, urlWorkRuleID: nil, urlWorkRuleVersion: nil,-            chapterSequence: nil, chapterSequenceRuleID: nil, chapterSequenceRuleVersion: nil,-            chapterTitle: nil, chapterTitleProvenance: try! FieldProvenance(kind: .none),-            note: "", rating: nil, firstCapturedAt: created, lastSharedAt: created,-            modifiedAt: created, workID: workID,-            workAssignmentProvenance: try! FieldProvenance(kind: .manual),-            workURLRuleID: nil, workURLRuleVersion: nil, workURLAssignmentKind: nil,-            workPatternID: nil, workPatternVersion: nil, intentionallyUnattached: false)--        return BackupV3Payload(-            entries: [entry], works: [work], sites: [site], titlePatterns: [], urlRules: [])-    }--    private func ordinaryV3Payload() -> (BackupV3Payload, UUID) {-        let host = "example.com"-        let patternID = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!-        let workID = UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!-        let entryID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!-        let rawURL = "https://example.com/read/7"--        let pattern = BackupV3TitlePattern(-            id: patternID, version: 3, isActive: true, createdAt: created,-            definition: .segment(work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1), ignored: []),-            siteHostname: host)--        let site = BackupV3Site(-            hostname: host, displayName: "Example", mode: .taught,-            titleInterpretation: .pattern,-            patternIDs: [patternID], urlRuleIDs: [], junkSuffixRule: nil)--        let work = BackupV3Work(-            id: workID, displayTitle: "Constellation", lastParsedTitle: "Constellation",-            siteHostname: host, urlIdentity: nil, urlIdentityState: .none,-            urlIdentityRuleID: nil, urlIdentityRuleVersion: nil, workURL: nil,-            genericNotes: "", type: .novel, genreTags: [], titleProvenance: .parsed,-            createdAt: created, modifiedAt: created, entryIDs: [entryID])--        let entry = BackupV3Entry(-            id: entryID, captureTitle: "Chapter 7 — Constellation", captureTitleSource: .host,-            rawURL: rawURL, canonicalURL: nil, hostname: host,-            entryIdentityKey: rawURL, identityKeyVersion: 1, identityBasis: .conservative,-            identityURLRuleID: nil, identityURLRuleVersion: nil,-            urlWorkIdentity: nil, urlWorkRuleID: nil, urlWorkRuleVersion: nil,-            chapterSequence: nil, chapterSequenceRuleID: nil, chapterSequenceRuleVersion: nil,-            chapterTitle: "Chapter 7",-            chapterTitleProvenance: try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 3),-            note: "", rating: nil, firstCapturedAt: created, lastSharedAt: created,-            modifiedAt: created, workID: workID,-            workAssignmentProvenance: try! FieldProvenance(kind: .pattern, patternID: patternID, patternVersion: 3),-            workURLRuleID: nil, workURLRuleVersion: nil, workURLAssignmentKind: nil,-            workPatternID: patternID, workPatternVersion: 3, intentionallyUnattached: false)--        return (BackupV3Payload(-            entries: [entry], works: [work], sites: [site],-            titlePatterns: [pattern], urlRules: []), patternID)-    }--    private var v2FixtureURL: URL {-        URL(fileURLWithPath: #filePath)-            .deletingLastPathComponent()-            .appending(path: "Fixtures/backup-v2-m2.3.json")-    } }  // MARK: - A live library for the upsert assertions
Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift Modified +0 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swiftindex 44ee841..bf7c7c7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConfigurationShellTests.swift@@ -17,7 +17,6 @@ struct ConfigurationShellTests {         let configuration = LibraryConfiguration(rootDirectory: root)          #expect(configuration.storeURL.path.hasSuffix("Library/Application Support/AsterismV2.sqlite"))-        #expect(configuration.legacyStoreURL.path.hasSuffix("Library/Application Support/Asterism.sqlite"))         #expect(configuration.lockURL.lastPathComponent == "Asterism.lock")         #expect(configuration.markerURL.lastPathComponent == "AsterismV2.ready")     }
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift Added +16 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swiftindex 4f797e7..a767688 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScaleFixtureTests.swift@@ -113,4 +113,20 @@ struct M4ScaleFixtureTests {         }         #expect(readyCounts.entries == 5_000)     }++    // MARK: - The signpost contract the device measurement depends on++    /// `make test-performance-m4-recent` measures Recent over this fixture by+    /// subscribing to the `RecentPublication` interval that+    /// `LibraryRepository+RecentPresentation` emits, and+    /// `M4ScaleRecentPerformanceUITests` names the subsystem, category and+    /// interval as literals. If either side drifts the device run still passes —+    /// it just measures nothing. Pinning the names on the host is the only place+    /// that drift fails cheaply.+    @Test("Performance signpost names stay compatible with the physical hooks")+    func signpostNames() {+        #expect(M2PerformanceSignposts.subsystem == "me.nore.ig.Asterism")+        #expect(M2PerformanceSignposts.category == "M2Performance")+        #expect(M2PerformanceSignposts.recentPublication == "RecentPublication")+    } }
Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift Modified +0 / -21
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swiftindex f9ecf4e..153ad8d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuntimeOpeningV2Tests.swift@@ -13,7 +13,6 @@ struct RuntimeOpeningV2Tests {          #expect(FileManager.default.fileExists(atPath: configuration.storeURL.path))         #expect(FileManager.default.fileExists(atPath: configuration.markerURL.path))-        #expect(!FileManager.default.fileExists(atPath: configuration.legacyStoreURL.path))         #expect(try await repository.debugCounts() == .zero)     } @@ -44,26 +43,6 @@ struct RuntimeOpeningV2Tests {         #expect(try await extensionRepository.debugCounts() == .zero)     } -    @Test("App preserves a legacy V1 migration opportunity instead of creating V2")-    func appRefusesLegacyPath() async throws {-        let directory = try V2OpeningTemporaryDirectory()-        let configuration = LibraryConfiguration(rootDirectory: directory.url)-        try FileManager.default.createDirectory(-            at: configuration.legacyStoreURL.deletingLastPathComponent(),-            withIntermediateDirectories: true-        )-        let evidence = Data("legacy evidence".utf8)-        try evidence.write(to: configuration.legacyStoreURL)--        await #expect(throws: LibraryRepositoryError.self) {-            try await LibraryRepository.openForApp(configuration, capabilities: .m2_0)-        }--        #expect(try Data(contentsOf: configuration.legacyStoreURL) == evidence)-        #expect(!FileManager.default.fileExists(atPath: configuration.storeURL.path))-        #expect(!FileManager.default.fileExists(atPath: configuration.markerURL.path))-    }-     @Test("Ready marker without V2 store fails closed")     func markerWithoutStore() async throws {         let directory = try V2OpeningTemporaryDirectory()
Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swiftindex 09c6721..b502e84 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4MigrationBootstrapTests.swift@@ -116,7 +116,7 @@ struct V4MigrationBootstrapTests {         let site = AsterismSchemaV3.Site()         site.hostname = host         site.modeRaw = SiteMode.taught.rawValue-        site.titleInterpretationRaw = BackupV3Types.FrozenTitleInterpretation.wholeCaptureTitle.rawValue+        site.titleInterpretationRaw = FrozenV3TitleInterpretation.wholeCaptureTitle.rawValue         site.workTitleTrimRule = trim         let raw = "https://\(host)/story/1"         let work = AsterismSchemaV3.Work()
docs/agent-notes/composed-teaching-ui.md Modified +15 / -23
diff --git a/docs/agent-notes/composed-teaching-ui.md b/docs/agent-notes/composed-teaching-ui.mdindex edcd4ed..decb99c 100644--- a/docs/agent-notes/composed-teaching-ui.md+++ b/docs/agent-notes/composed-teaching-ui.md@@ -1,15 +1,12 @@-# Composed teaching UI (stream 3, tasks 18–22)--## The composed surface commits against the real V4 runtime (task 25 — DONE)+# Composed teaching UI  The composed teaching surface (`ComposedTeachingView`/`ComposedTeachingViewModel`) drives `projectComposedTeaching` / `commitComposedTeaching`, which require the-repository opened with `.m4` and a V4-valid store. As of task 25 this holds in-production: the app opens `openV4ForApp` and `AsterismCapabilities.current == .m4`,-so the EntryDetail/Recent teach and Work-detail re-teach entry points commit-successfully in production.+repository opened at `.m4` against a store `V4LibraryValidator` accepts — which is+what the app opens, so the EntryDetail/Recent teach and Work-detail re-teach entry+points all commit in production. -## UI-test path folds into the normal V4 setup+## UI-test path folds into the normal setup  The `.composed` UI-test fixture (`seeded-composed` scenario) no longer needs a special bootstrap. `AppLibraryModel.bootstrap()` now opens `openV4ForApp` for all@@ -36,21 +33,16 @@ They are covered by Core tests (`LookupFirstCaptureStateTests`, `LookupCaptureViewModel`). If a real extension-launch UI test is wanted later, it needs share-sheet automation infrastructure that does not exist today. -## Task 24 cleanup outcome (dead-code)--- `TeachingViewModel.swift` and its 972-line `TeachingViewModelTests.swift` were-  **deleted** in task 24: the type had no production caller after the phase-19-  M3-surface deletion (only its own tests referenced it), and its M2 title-editor-  view-model mechanics are covered for the composed surface by-  `ComposedTeachingViewModelTests`.-- `TeachingComponents.swift` is **kept**: `TitleChipView` and `FlowLayout` are-  used by `ComposedTeachingView` and `ComposedURLDetailsEditor`.-- No dead M2-era extension capture wiring was found: the M2 capture stack-  (`CaptureViewModel`/`ObservableCaptureViewModel`/`CaptureView`/-  `CaptureCoordinator`) is fully reused by the lookup-first `.new` handoff (Q26)-  in `ShareCaptureRootView`. `WorkOnlyTitleCleaner`, `V3LibraryValidator`, and-  `SiteTitleInterpretation` were removed by the task-25 finalization.-## Title selection after Decision 8 (tasks 26–31 — DONE)+## The M2 capture stack is shared, not dead++The M2 capture stack (`CaptureViewModel`/`ObservableCaptureViewModel`/+`CaptureView`/`CaptureCoordinator`) looks superseded but is fully reused by the+lookup-first `.new` handoff (Q26) in `ShareCaptureRootView`. Likewise+`TeachingComponents.swift`: `TitleChipView` and `FlowLayout` are used by+`ComposedTeachingView` and `ComposedURLDetailsEditor`. Do not delete either on+the strength of its name.++## Title selection after Decision 8  There is **no title-mode picker**. `ComposedTeachingViewModel` holds one chip row (`titleChips` + `titleRoles`, roles Work / chapter / ignore) and the rule form is
docs/agent-notes/schema-migration.md Modified +63 / -73
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex d1e7124..55e3114 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,63 +1,60 @@-# Schema migration (V3 → V4)+# Schema migration -Schema V4 is live. The app and the share extension both open `openV4ForApp` /-`openV4ForExtension`. Read the "Current state" section; everything under-"History" is background for the *next* schema bump and describes states that no-longer exist.+Schema **V5** is live. The app and the share extension both open `openV4ForApp` /+`openV4ForExtension` (the names kept their V4 spelling; they open V5). 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 AsterismSchemaV4 { @Model final class Entry … }`+  classes live in `extension AsterismSchemaV5 { @Model final class Entry … }`   (`Models.swift`) and are reached by top-level typealiases-  (`typealias Entry = AsterismSchemaV4.Entry`). The frozen pre-M4 shape is nested-  in `AsterismSchemaV3` (`AsterismSchemaV3.swift`): its `Site` keeps-  `titleInterpretationRaw` + `workTitleTrimRule` and has none of the M4-additive-  columns.-- **`Site.titleInterpretationRaw` and `workTitleTrimRule` are gone** from the-  live V4 `Site`. `V4Migration.buildSidecar` reads them off the frozen-  `AsterismSchemaV3.Site` before conversion. The only surviving references are-  the frozen V3 snapshot, the frozen `BackupV3Types` wire copies-  (`FrozenTitleInterpretation` / `FrozenWorkTitleTrimRule`, keeping the 3/3-  format byte-identical), the V2→V3 / V3→V4 backup mappers, and the-  migration-read path. No live reader touches them.-- **"Work-only" is now derived, not stored:** `Site.isWorkOnlyTitleRule` (the-  active pattern's definition is `.wholeTitle`). Trims apply via-  `TitleTrimApplicator` on the pattern's `trimPrefix`/`trimSuffix`. A taught Site-  always holds exactly one active title pattern (Decision 5).-- **`AsterismV4MigrationPlan` = `[V3, V4]` with a `.lightweight` stage.** The-  Work-only → whole-title-rule transform and the durable-sidecar crash safety are-  orchestrated manually by `openV4ForApp` under the exclusive lock, app-only:-  read the V3 shape → write the sidecar (before conversion) → open V4 →-  `V4Migration.runCompletionPass` (create whole-title patterns with the-  pre-allocated UUIDs under an exact-idempotence guard; backfill-  `conservativeIdentityKey`) → `V4LibraryValidator` → publish `AsterismV4.ready`-  (`"4"`) → delete the V3 marker and the sidecar. See-  `LibraryRepository+V4Bootstrap.swift` and `V4Migration.swift`.-- **An empty store is marked ready at birth** (T-1969, Decision 9 in-  `specs/unified-teaching-composition/`). `openV4ForApp` returns an open ready-  library or throws — there is no `.setupRequired` and no first-run screen. Both-  the "no store" and "store, no markers, no sidecar" branches converge on one-  check: publish the marker iff `v3Counts == .zero`, measured *after* creation-  rather than assumed. A nonempty unmarked store still hits the-  unverifiable-partial-migration throw (Q25), and a V3 marker whose store is-  gone now fails closed instead of starting an empty library over it.-- **Deleted:** `V3LibraryValidator` (→ `V4LibraryValidator`),-  `SiteTitleInterpretation`, `WorkOnlyTitleCleaner`, `openV3ForApp` /-  `openV3ForExtension`, and the M3 URL-teaching commit subsystem-  (`URLTeachingProjection` + the `commit/projectURLTeaching*` repository-  methods). `openV3Container` survives only as an internal helper used once by-  the bootstrap to read the pre-migration shape. `reviewURLIdentity` and-  `URLIdentityPlanner` survive (Req 7.2). `commitTeaching` survives minus its-  interpretation stamp. The `WorkTitleTrimRule` *struct* is retained as the-  frozen V3 Site's stored-column type.-- **Capability gate is `.m4`** (`AsterismCapabilities.current`). `BackupV3Codec`-  is pinned to its historical `"m3"` gate so every earlier backup stays-  byte-identical; `BackupV4Codec` carries `"m4"`.-- **Backup:** `planV4` / `BackupV4Exporter` / the V4 confirm-import commit path-  are primary. Import dispatches 2/2→V4, 3/3→V4, and native 4/4; the 3/3 path-  keeps `BackupV3ReferenceValidator` (Q18). The frozen 2/2 and 3/3 codecs and-  both mappers stay.+  (`typealias Entry = AsterismSchemaV5.Entry`). `AsterismSchemaV3` and+  `AsterismSchemaV4` are frozen snapshots.+- **`AsterismV5MigrationPlan` = `[V3, V4, V5]`**, both stages `.lightweight`:+  V3 → V4 adds the M4-additive columns and drops the two `Site` columns, V4 → V5+  adds the two relationships and their inverses. **Do not trim this chain to+  match the stores you happen to have.** Both failure modes are measured and+  recorded in `AsterismSchemaV5.swift:26-42`: freezing V4 as a verbatim copy of+  V5 aborts with `NSInvalidArgumentException` "Duplicate version checksums+  detected", and dropping the V4 → V5 stage fails the open with+  `NSCocoaErrorDomain` 134504 "Cannot use staged migration with an unknown+  coordinator model version". Whether a store *recorded at V5* opens under a+  `[V5]`-only plan is unproven — probe it before assuming (T-2114).+- **The data passes are not SwiftData custom stages.** A custom stage does not+  fire between structurally identical schemas and would also run inside the share+  extension, which must never migrate. The app bootstrap runs them under the+  exclusive lock: `V4Migration.buildSidecar` / `runCompletionPass` for V3 → V4,+  `V5RelationshipPass` for V4 → V5. See `LibraryRepository+V4Bootstrap.swift`.+- **The readiness marker holds `"5"`** — the only version the extension opens+  (Q14). An *empty* store is marked ready at birth, already in the state the+  relationship pass produces (Q26). A populated library still marked `"4"` runs+  the relationship pass on its next app open and is republished at `"5"` (Q31).+  A nonempty unmarked store hits the unverifiable-partial-migration throw (Q25).+- **`Site.titleInterpretationRaw` and `workTitleTrimRule` are gone** from the live+  `Site`. They survive only on the frozen `AsterismSchemaV3.Site`, read once by+  `V4Migration.buildSidecar` through `FrozenV3TitleInterpretation` (declared in+  `V4Migration.swift` — it used to live in `BackupV3Types`, which went with the+  3/3 archive format). "Work-only" is derived, not stored:+  `Site.isWorkOnlyTitleRule` is true when the active pattern is `.wholeTitle`.+- **Capability gate is `.m4`** (`AsterismCapabilities.current`). `BackupV4Codec`+  carries `"m4"`.+- **Backup is 4/4 only.** `planV4` / `BackupV4Exporter` / the V4 confirm-import+  commit path. The `2/2` and `3/3` import paths, both mappers, and their codecs+  were **deleted** — recovering a pre-M3.5 archive means checking out a build+  that still carries them. `LegacyV2DateFormatter` and `DuplicateJSONKeyValidator`+  survived that deletion in `BackupJSONCodecSupport.swift`; the live V4 codec uses+  both.+- **`AsterismSchemaV2` is a mislabel, not a schema.** Its `models` array resolves+  through the top-level typealiases to V5's classes and its plan has no stages, so+  `openForApp` / `openForExtension` mean "open the live models with a `2.0.0`+  stamp and no migration". Nothing ships through it — it is the test-store+  opener, reached by four test files (`RuntimeOpeningV2Tests`, plus file-private+  container helpers in `SchemaV2Tests`, `RepositoryTeachingTests` and+  `RepositoryCaptureTests`). Do not size the rename off a grep for `openForApp`:+  most hits are test-local helpers of that name that call `openV4ForApp`.+  See T-2113.  ## History — lessons for the next schema bump @@ -73,30 +70,23 @@ AsterismCore.Site for PersistentIdentifier(... Site/p1) to Site.  Two `@Model` types sharing one entity name in the same module makes SwiftData's global entity registry ambiguous, and materialization fails even while the-snapshots are inert. The V1 precedent (`AsterismV1MigrationSupport`) sidesteps-this with a separate module, but in-process V3→V4 migration needs both schema-versions inside `AsterismCore`.+snapshots are inert. -**The fix, now proven in production:** nest *every* entity so there are zero+**The fix, now proven twice in production:** nest *every* entity so there are zero top-level `@Model` types, and make the top-level names typealiases.-`V4MigrationBootstrapTests` seeds genuine frozen-`AsterismSchemaV3` stores and-`openV4ForApp` migrates them in-process under the `[V3,V4]` plan with no-collision. Do the same for V5 — do not conclude from the crash above that-in-module snapshots are impossible.+`V4MigrationBootstrapTests` seeds genuine frozen-`AsterismSchemaV3` stores and the+bootstrap migrates them in-process with no collision. Do the same for V6.  ### A custom migration stage is the wrong tool here -`MigrationStage.custom(V3→V4, willMigrate:…)` never fires when the two versions-share structurally identical models — SwiftData sees no schema diff (probed-directly). It would also run inside the share extension, which must never-migrate (Req 5.4). That is why the sidecar is written by `openV4ForApp` rather-than from `willMigrate`.+`MigrationStage.custom(willMigrate:)` never fires when the two versions share+structurally identical models — SwiftData sees no schema diff (probed directly).+It would also run inside the share extension, which must never migrate. That is+why the sidecar is written by the bootstrap rather than from `willMigrate`.  ### The freeze and the column drop are one step -Freezing V3 is not implementable while the live classes are still V3's, and-dropping the two `Site` columns is inseparable from rewriting every reader of-them. Decision 6 records why the freeze moved out of task 1, and Decision 7 why-finalization sequenced *after* the Core runtime rewrite rather than inside the-migration phase. Expect the same coupling next time: plan the snapshot freeze to-land with the phase that rewrites the readers, not before it.+Freezing a schema version is not implementable while the live classes are still+that version's, and dropping columns is inseparable from rewriting every reader of+them. Expect the same coupling next time: plan the snapshot freeze to land with+the phase that rewrites the readers, not before it.
docs/agent-notes/testing.md Modified +9 / -8
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex ab01816..037c4f0 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -19,15 +19,16 @@ least one simulator UI test that reaches it from app launch via real navigation (see `AsterismUITests/ComposedSurfaceUITests.swift` for the pattern, and use the seeded scenarios in `UITestLaunchSupport`). -The same rule applies to the *seeders*. T-1947: `seedM2PerformanceFixture` guarded-`capabilities == .m2_3` and `seedM3PerformanceFixture` guarded `== .m3` while the-app runs `AsterismCapabilities.current`, so both threw at launch — and because the+The same rule applies to the *seeders*. T-1947: the M2 and M3 scale seeders each+guarded on a gate *identity* (`capabilities == .m2_3`, `== .m3`) while the app+runs `AsterismCapabilities.current`, so both threw at launch — and because the only suites driving them were device-gated, the sole symptom was a-`waitForExistence` timeout nobody ever saw. Two fixes, both now in place: seeders-guard on the *features* they write (`allows(patternForm:)`, `supportsArticles`),-never on a gate identity; and every seeded scenario carries one-`test…ScenarioReachesRecent` check that is **not** device-gated, so a throwing-seeder fails on `make test-ui`.+`waitForExistence` timeout nobody ever saw. Those seeders and their fixtures have+since been deleted with the device suites that were their only callers, but the+two fixes they forced still bind everything that replaces them: seeders guard on+the *features* they write (`allows(patternForm:)`, `supportsArticles`), never on a+gate identity; and every seeded scenario carries one `test…ScenarioReachesRecent`+check that is **not** device-gated, so a throwing seeder fails on `make test-ui`.  ## Incoherent-library UI fixtures must reopen after seeding 
docs/asterism-design.md Modified +2 / -2
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 747a774..94de2c0 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -3,7 +3,7 @@ **App name:** Asterism **Subtitle:** Notes for Serial Reading **Platform:** iOS (iOS 26+), SwiftUI, SwiftData + CloudKit-**Status:** Under implementation. M1–M3.5 shipped; M4 is split into three specs and M5 follows (§14). This document holds the product design; per-milestone requirements, designs, and decision logs live in `specs/`, and where they contradict this file they are the newer record.+**Status:** v1 feature-complete. M1–M5 shipped, M4 across three specs (§14). This document holds the product design; per-milestone requirements, designs, and decision logs live in `specs/`, and where they contradict this file they are the newer record. **Distribution:** v1 is personal-use only — a test bed to accumulate real data before v2. App Store distribution is a v2 possibility (§13).  ---@@ -538,7 +538,7 @@ Four things the spec settled that this paragraph did not anticipate, each of whi  Deliberately last, and it paid: the reconciler was written against the states M4a and M4b had already made observable rather than against a guess about which kinds occur. -**M5 — Polish & export.** *Spec: `specs/polish-and-export/`.*+**M5 — Polish & export.** *Shipped. Spec: `specs/polish-and-export/`.* Markdown exports (per-work, per-entry); search on both tabs; rating pulse; Sites settings screen; deletion prompts and edge behaviours; visual pass (Liquid Glass conventions per the mockups). Also the **articles-mode exit** M2 deferred to here (§4.6): re-teaching an articles site is accepted from the Sites screen and from nowhere else, returning the site to taught mode with the normal retroactive semantics (§4.7) and clearing its junk-suffix rule. There is still no reverse mode toggle, and the gate that stops inbox-driven teaching from converting an articles site by accident holds on every other path.  Sequencing note: the whole M4 group precedes M5 because sync issues surface only with time and multiple devices in play — the earlier CloudKit runs against real captures, the more of its edge cases M5's polish period absorbs. The split does not weaken that: M4a is short and offline, and M4b still gets mirroring in front of real data well before M5. The accepted cost is a window during M4b in which duplicates accumulate unreconciled, which M4a guarantees will degrade rather than fail.
specs/OVERVIEW.md Modified +7 / -1
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 8aeab4e..849da15 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -42,6 +42,8 @@ Adds exact URL-derived identity, safe re-share editing, conflict recovery, confi  **Carried forward — do not let these disappear into the decision log:** +- **The `2/2` and `3/3` import paths this spec built no longer exist** (2026-08-05). Native `4/4` is the only accepted archive version; `LegacyBackupV2Codec`, `BackupV3Codec`, both backup mappers and `BackupV3ReferenceValidator` are deleted, along with the `2/2` fixture. Reading a pre-M3.5 archive means checking out a build that still carries them. The requirements and design here are left as the M3 record.+ - **The first-run setup flow this spec designed no longer exists** (T-1919, T-1969, 2026-07-27). Readiness is published when the empty store is created, so a fresh install opens straight into an empty library. **Reqs 1.1, 1.4, 1.5, 1.19 and the first-run half of 1.3 are superseded**, as is the "publish readiness when it was absent" clause of 1.18 — import now always commits into an already-ready library. `FirstRunLibrarySetupModel`, `confirmStartEmpty`, `BackupImportCommitMode` and `SetupOrReadyEmptyState` are deleted. **Settings → Import (fill and destructive replace) is unchanged and still the supported path.** The requirements, design and tasks here are left as the M3 record; the reasoning, the alternatives, and the one guard deliberately given up are in Decision 9 of [unified-teaching-composition](unified-teaching-composition/decision_log.md), which owns the bootstrap state table.  - [decision_log.md](url-identity-re-share/decision_log.md)@@ -69,7 +71,7 @@ Makes three recoverable graph states degrade instead of failing the library, ahe - **Req 5.5 is unmet as measured** (T-1946). Diagnosis re-derivation over the 5,000-Entry fixture runs 0.268–0.278 s against a 250 ms budget, on all three paths. The three assertions ship wrapped in `withKnownIssue`, carrying the numbers, so a fix self-reports (Decision 11). The requirement specifies a *device* measurement, and the `AsterismCore` package suite cannot run on device at all — the one calibration point available puts the device ~2.3× faster, which would place the scan near 0.12 s, but that is an inference and not a measurement. **Settling it needs a signpost around `refreshDiagnostics` and a device UI test**, mirroring what task 37 built for Recent. - **Entry detail still fails wholesale for a single Site row with an illegal tuple** (Q39, T-1949), where Recent degrades for the same condition. Not a Req 1.1 state, so out of scope here; the asymmetry is deliberate and recorded. - **A `.siteTuple` hostname carrying no Entry shows a re-teach button that does nothing** (Q53, T-1948). The composed teaching surface is entered from an Entry. Given Q52 — the diagnosis screen is the *only* route to repair the one clearable class — this is that route failing for one shape. Minimum fix is withholding the button.-- **The M2 and M3 scale suites are dead** (T-1947) and were before this milestone: both guard on capabilities the app no longer runs (`.m2_3` / `.m3` against `.m4`). They should be repaired or deleted; neither is in this spec's scope.+- ~~**The M2 and M3 scale suites are dead** (T-1947)~~ — **resolved 2026-08-05.** The M2 seeder had already been repaired to guard on features rather than a gate identity; the M3 one was still pinned to `.m3` against a `.m4` current gate and always threw. Both apparatuses are now deleted along with the `make test-performance` device target, and the signpost names the surviving `make test-performance-m4-recent` measurement depends on are pinned by a test that runs in the default `make test-core`. - **Teaching preview failures all render one generic message** (T-1950), so the typed `.quarantined` reason task 22 added never reaches the reader. Pre-existing and consistent across teaching surfaces; low priority because every UI path that could reach that refusal has since withdrawn its action, leaving it reachable only by a race.  - [decision_log.md](library-integrity-tolerance/decision_log.md)@@ -107,6 +109,10 @@ Phase 2 of the three-way M4 split. Mirroring on separate containers per configur  **[Relational References](#relational-references) shipped first, as required** (Q20, satisfied by `94419e0` — Q26) — without it this spec would have needed a pending-reference taxonomy, notification-driven re-evaluation, and a widened archive format, most of which later milestones delete. Two facts flow back from its landing: reconciliation gains a heal for the nil-relationship-with-surviving-row state only sync can produce (Req 1.8, Q28), and relationship writes against a large inverse array measured superlinear, which the reconciliation and import designs must cost (Q27). +**Carried forward — do not let this disappear into the decision log:**++- **Req 4.6 is no longer true** (2026-08-05). It requires that "an archive in the 2/2, 3/3, or 4/4 format SHALL import"; the `2/2` and `3/3` paths have since been deleted and only native `4/4` imports. The requirement stands as this milestone's record — it was satisfied when written — but nothing should be built on it.+ **Two reversals worth carrying forward, both recorded rather than quietly applied:**  - **Phase 1's tolerated set was incomplete.** `V4LibraryValidator.swift:351`, `:604` and `:637` quarantine a hostname when a taught Site's active pattern, an Entry's cited pattern, or a manually-assigned Entry's Work has not arrived — and quarantine blocks export and disables rule application. Phase 1's Decision 4 claimed its three states were what sync produces; Decision 1 here corrects that.
specs/library-integrity-tolerance/prerequisites.md Modified +1 / -1
diff --git a/specs/library-integrity-tolerance/prerequisites.md b/specs/library-integrity-tolerance/prerequisites.mdindex 3133054..8c90b8e 100644--- a/specs/library-integrity-tolerance/prerequisites.md+++ b/specs/library-integrity-tolerance/prerequisites.md@@ -7,7 +7,7 @@ These tasks require human intervention outside of code. - [x] Connect a physical iPhone and confirm it appears in `make devices`. Task 1 measures the performance baseline, and the project's protocol (20 runs, 19th value) is device-only — the simulator numbers are not comparable, and `M3ScalePerformanceUITests` skips outright when not on a device. - [x] Register the `Personal` share-extension App ID `me.nore.ig.Asterism.ShareExtension` with the `group.me.nore.ig.Asterism` App Group. Installing once from Xcode under the `Asterism Personal` scheme does it. Without the profile, `make test-performance` falls back to the wildcard `iOS Team Provisioning Profile: *`, which carries no App Group, and reports a misleading *"No Accounts"* error. `make install` is unaffected because it builds the `Development` scheme. - [ ] Keep the device **unlocked** for the duration of any device run. A locked phone yields `com.apple.dt.deviceprep Code=-3 "Unlock <device> to Continue"` partway through and corrupts the run.-- [ ] **Back up the device before any performance run, and approve each run at the time it happens.** `make test-performance` / `-m3` build the `Personal` configuration and install over the real app on the phone. The task list asking for a measurement is NOT approval to run one — see the rule in the project's `CLAUDE.md`. The Makefile prompts; nobody may answer that prompt on the owner's behalf or set `CONFIRM_DEVICE_RUN=1` to skip it.+- [ ] **Back up the device before any performance run, and approve each run at the time it happens.** `make test-performance-m4-recent` builds the `Personal` configuration and installs over the real app on the phone. (It is the only such target left — the `make test-performance` and `-m3` targets this line originally named have both been deleted; check `CLAUDE.md` for the current list rather than trusting this one.) The task list asking for a measurement is NOT approval to run one — see the rule in the project's `CLAUDE.md`. The Makefile prompts; nobody may answer that prompt on the owner's behalf or set `CONFIRM_DEVICE_RUN=1` to skip it.  ## Before Testing 

Things to double-check

Pre-M3.5 archives are unreadable in-app, permanently.

The deliberate consequence. If any 2/2 or 3/3 archive still matters to you, restore it on a build at or before 52c6504 and re-export at 4/4 before this lands anywhere you rely on. The changelog now says so; nothing in the app will remind you.

The UI test bundle was not run.

make test-core (1157 checks), make build and make test-quick are all green. make test-ui was not run — it is a longer simulator suite and no UI-facing code changed, but the .scale launch scenario and one launch-argument test were removed, so it is the suite most likely to notice if something was missed.

No device target was run, by design.

Per CLAUDE.md, nothing touching the physical iPhone was executed at any point, including by the subagents. make test-performance-m4-recent remains the only device target and still needs approval at the time of running.