Work and reading status (T-2306): two reader-entered statuses and a verdict on every work, schema V10 over a frozen V9, the 9/10 archive, duplicate resolution and merge, the Works list, the detail screen's finished-reading rule, the UI journeys and the docs — 25 commits, eight phases, seven design-review rounds.
Schema V10 adds workStatusRaw, readingStatusRaw, verdict to Work; V9 is the frozen snapshot, one lightweight stage, markers "9"/"10", the V8 stage retired (Q18).
The three fields ride every carrier — snapshot, authored content, draft, edit basis, variant side, merge outcome, archive record — with a stated required/defaulted policy per carrier (Q40, Q47, Q52, Q69).
Finished reading requires a finished work: a dialog on the picker and again at commit when the pair changed (Decision 1, Q20, Q37); on the phone the dialog is a popover and its Cancel is the dismiss region (Decision 2, proposed).
Abandoned rows dim and sort last within each section under every sort; two closed-vocabulary filters; four glyphs from one presentation table (Q27, Q31, Q36).
Archive at 9/10 with the three fields required on the wire; an 8/9 archive is refused naming both pairs (Q17, Q34).
Owner's steps remain: the 8/9 archive export from every device before the first V10 install, the dev CloudKit field publish (the dev app is installed; open it once), and the Decision 2 ruling.
Ready to push
Ready to push. Four review agents (reuse, quality, efficiency, spec) raised no major finding; 11 minor and nit items were fixed in the working tree and 4 were skipped with a stated reason. The package suite passed on the first run with coverage (2,332 tests), and the app bundle with the Mac build passed after the fixes. Two things are the owner's before merge: Decision 2 (the finished-reading dialog's third action is the platform's dismiss region on the phone, so Req 3.1 and 10.2 read as unmet until the wording is amended or a presentation that draws Cancel is asked for) and the device steps in prerequisites.md.
Pass rate: 100% (2301 of 2301)
New tests: 97
Diff coverage: 96% (3962 of 4117 added lines)
working-tree Fixes applied in this review 8624d22 T-2306: changelog for phase 8, spec marked Done, docs-review fixes d5d749f T-2306: Full-suite verification and the implementation record 4541e2f T-2306: Documentation pass for work and reading status d67d8e6 T-2306: changelog for phase 7 5223292 T-2306: Sharpen the status UI journeys after the Phase 7 review 4730aab T-2306: Seed statuses into the works-options fixture and write the UI journeys 274dc37 T-2306: changelog for phase 6 c221a01 T-2306: Design-review follow-ups on the finished-reading rule 2e6d0db T-2306: Work detail statuses, the verdict and the finished-reading rule 33cc6c7 T-2306: changelog for phase 5 238540b T-2306: Phase 5 review follow-ups — merge label separator and a stale count e9cfb7f T-2306: Works list and filters — status glyphs, abandoned-last, two filter dimensions 632f602 T-2306: changelog for phase 4 50bb750 T-2306: Pin the required status fields and correct two stale comments 5382bf6 T-2306: Backup archive generation 9/10 — BackupV9*, the three status fields on the wire e770618 T-2306: changelog for phase 3 e63ec90 T-2306: Design-review follow-ups for duplicate resolution and merge ec3e75b T-2306: Duplicate resolution and merge over the three status fields 3a4f638 T-2306: changelog for phase 2 3eca9ea T-2306: Design-review follow-ups on the Phase 2 status write chain 7315f51 T-2306: Thread work status, reading status and verdict through the write chain 9674ae4 T-2306: changelog for phase 1 4c93256 T-2306: Phase 1 review fixes — retire WorkType, trim version pin, note banners 74d7938 T-2306: Schema V10, status enums and marker generation 10 f813950 T-2306: Work and reading status spec Every work in the library now records two things it never did before. The first is the author's side: is the story still being written? It can be ongoing, finished, or on hiatus. The second is the reader's side: where do you stand with it? That can be reading, finished, or abandoned. Once you are done with a work either way, an optional free-text verdict goes with it — the app asks "How was it?" if you finished, and "Why did you stop?" if you gave up.
You set both by hand, on the work's own page, in the same edit mode that already edits the title and the tags. Nothing is guessed from the website, and capturing a new chapter never changes either value.
{n} notes with the thumbs — and shows your verdict as a paragraph above the notes.The library could tell you what you had captured, but not what you had done with it. A hundred works with no way to see which are dead, which you dropped, and what you thought of the ones you finished is an archive, not a reading library.
Three new columns means the app's database changed shape — schema V9 to V10 — and it converts your library the first time you open it. That is a one-way door: once converted, the previous build cannot open it. So the conversion is purely additive, it announces itself only after the converted library checks out, and the only way back is a backup file taken beforehand. The backup format moved too, from version pair 8/9 to 9/10.
Three defaulted, non-optional columns land on Work in Models.swift — workStatusRaw, readingStatusRaw, verdict — with two tolerant computed accessors over them on the existing titleProvenance pattern. Two new enums live in DomainEnums.swift, and ReadingStatus.isDone is the one derived predicate everything that shows or hides the verdict reads.
The live model bodies moved from extension AsterismSchemaV9 to extension AsterismSchemaV10; the previous live shape was copied into AsterismSchemaV9.swift as the frozen snapshot, and AsterismSchemaV8.swift was deleted with the stage that named it. AsterismV10MigrationPlan is a single .lightweight stage: the property initialisers become Core Data attribute defaults, and those defaults are the entire conversion. The readiness marker moves 9 to 10 only after the converted store validates.
Every carrier of authored work content gained the three fields — WorkSnapshot, WorkAuthoredContent, WorkMetadataDraft, WorkEditBasis, WorkVariantSide, WorkMergeOutcome, DuplicateResolutionField, WorkMergeField, BackupV9Work. Whether the parameters have defaults was decided per type by what an omission costs: required on WorkMetadataDraft and WorkVariantSide, where an omission silently destroys reader text, defaulted on WorkEditBasis and WorkMergeOutcome, where it can only over-refuse or mis-display.
WorkVariantUnion.fold backs both merge and duplicate resolution, so a losing verdict reaches the audit block on both paths.WorkStatusPresentation holds names, symbols, hues, dimension-qualified labels and identifiers; four surfaces read it rather than spelling their own.filter calls at the end of WorksSort.apply. Both that and the view's split-by-emptiness are stable, so the requirement holds under every sort, query and filter from one line.setDraftWorkStatus and setDraftReadingStatus, never raw writes, and the same rule runs again in commitEditing() gated on the draft pair differing from the stored pair. The dialog carries its context on a presenting: value because SwiftUI clears it before the button action runs.Retiring the V8 stage makes a marker-8 library unopenable. The archive generation renames wholesale, so a pre-feature archive is refused by version. Every existing VariantID changes once because orderComponents gained three parts. And the finished-reading invariant is a UI rule, not a data guarantee — two devices can each make a valid edit that together violate it.
V10 is the first version in this project to add a non-optional scalar to an existing table under a bare lightweight stage. That loses a relation every earlier stage relied on: the live stored shape is no longer a subset of the frozen one, so a stale registration in SwiftData's global entity registry can now cost a column that will not save rather than merely one that will not load. V9RecordedStoreFixture's create-seed-save-release ordering and make test-core's --no-parallel are the only things holding the registry coherent. V9RecordedStoreTests asserts the raw columns after conversion, not the accessors — a tolerant accessor would answer .ongoing even if the default never landed.
ToleratedEnum.read makes an unknown spelling (empty string included) read as the default everywhere it is shown or filtered, while BackupArchiveProjection.requireRepresentableValues refuses to export the same value by name. Writing is unaffected: updateWork writes what the picker showed, so an unrelated edit replaces an unknown raw rather than preserving it — the draft has no representation for a value the picker cannot name.
10 is the first marker longer than one character. Every comparison is string equality or set membership, so nothing ordered had to change — but five test suites used the literal 10 as their canonical unrecognised marker and were moved to 99 before the constant moved.updateWork.normalizeVerdict is the only trim site; import writes the archive value verbatim. The pre-push-review fixes close the resulting asymmetry at the read sites — the detail paragraph, the reconciler's propagation guard and the fold now all test M2Unicode.isBlank, so blank means absent wherever the verdict is shown, propagated or recorded.Verdict: line. A verdict is multi-line reader text sitting inside the audit block's structured region, so it goes through the same escapedField as the merged-from header: an unescaped blank line ends the region early, and a typed --- Merged from: would forge a boundary.abandoned must never overwrite a sibling that has. There is no propagates gate — that exists only because a type may be .removed.confirmationDialog as an anchored popover on iPhone at every type size, and a popover omits the declared cancel button. The identifier exists on no iPhone presentation; the third outcome is the dismiss region.CloudKit must publish the three fields once from a signed-in Development run before a second dev device syncs. A V9 device syncing against V10 rows is an explicit non-goal. An abandoned row wearing a removed type's dimmed pill double-dims to about a quarter opacity, accepted rather than clamped. And make test-ui's three failures are the pre-existing scale trio, not this branch's.
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV10.swift
Why it matters. Three new Work columns need a schema version, and the plan could either grow to three stages or drop the oldest one now that every device has passed it.
What to look at. AsterismSchemaV10 / AsterismV10MigrationPlan (whole file); AsterismSchemaV9.swift becomes the frozen snapshot; AsterismSchemaV8.swift deleted
Packages/AsterismCore/Sources/AsterismCore/Models.swift
Why it matters. A newer build (or a corrupt sync) can put a spelling in the column that this build has no case for, and a crash or a silent data drop are both worse than a default.
What to look at. Work.workStatus / Work.readingStatus computed accessors, Models.swift:389-405, over workStatusRaw / readingStatusRaw / verdict declared at :317-341
Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift
Why it matters. Merge and duplicate resolution both discard one side's content, and a second implementation of that is a second set of answers waiting to disagree.
What to look at. WorkVariantUnion.fold, WorkVariantUnion.swift:121-250, with WorkMergeAuditFormatter.block / escapedField at WorkMergePlanner.swift:18-49
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift
Why it matters. A work can be several stored rows. Writing an edited field to only some of them re-tears the group that reconciliation just healed.
What to look at. updateWork every-row loop, LibraryRepository.swift:1222-1241, with normalizeVerdict at :1788
Asterism/Asterism/ViewModels/WorkDetailModel.swift
Why it matters. Decision 1's invariant has to be enforced without blocking a mis-tap, without destroying a typed verdict, and without firing on a stored pair the reader never touched.
Asterism/Asterism/ViewModels/WorksListOptions.swift
Why it matters. Req 5.3 asks for abandoned-last within every section, under every sort, with or without a query and filters — which reads like section-aware ordering code.
What to look at. WorksSort.abandonedLast, WorksListOptions.swift:88-90, applied at :79
Packages/AsterismCore/Sources/AsterismCore/BackupV9Types.swift
Why it matters. Renaming the whole archive set rather than keeping a compatibility reader raises the question of what a decoder should do with a record missing the new fields.
What to look at. BackupV9Work.workStatus / .readingStatus / .verdict, BackupV9Types.swift:247-291; formatVersion 9 / schemaVersion 10 at :19-20
Decision 1. Reading status finished is valid only when the work status is finished; choosing it elsewhere offers three outcomes — mark the work finished too, use abandoned, or back out. The rule runs on the picker transitions and again at commit whenever the draft pair differs from the stored pair (Q20).
Enforcing it in storage was rejected: two devices can each make a valid edit that together violate it, so a stored violation must be tolerated and displayed, not refused. Req 3.5 makes that explicit and an unchanged commit writes the violating pair back as-is. A fourth reading value, caught up, was rejected as a derived state the app cannot verify.
Decision 2 (status proposed), promoted from Q65. iOS 26 draws this dialog on iPhone as an anchored popover at every type size, and a popover omits the declared cancel button — work-detail-finished-cancel exists on no iPhone presentation. The presentation stays as declared: macOS and regular width may draw the button, the delete dialog carries the same declaration for the same reason, and .alert would split the pattern all five detail dialogs share.
Req 3.1's "exactly three actions" and Req 10.2's "present all three" therefore read as unmet until the owner either amends them to "three outcomes, the third being the platform's dismissal" or asks for a presentation that draws Cancel. Amending a requirement mid-feature was rejected as the implementer's call to make.
Q18. The plan is [V9, V10] with one lightweight stage, and marker 8 joins the retired digits. The precondition is retire-migration-chain Decision 6's: every device confirmed at marker 9 on 2026-09-04. A store older than V9 now fails closed with NSCocoaErrorDomain 134504 and its recovery is the backup archive, which V4RecordedStoreTests pins.
Q40, Q47, Q52 and Q69. Nine types gained the same three fields, and a blanket rule either way would have been wrong. WorkMetadataDraft's three parameters are required (Q40) because updateWork writes all three to every row, so a draft built without them silently resets a reader's statuses with no compiler error. WorkVariantSide's are required for a sharper version of the same hazard (Q52): an omitted verdict drops out of the audit block, and on the resolution path the row that held it is then deleted — reader text lost for good.
WorkEditBasis's are defaulted (Q47) because the two fail in opposite directions: an omitted basis field can only fail to match a non-default survivor, which is a visible refusal rather than a silent overwrite. WorkMergeOutcome's sit with it (Q69): the outcome only describes the target after the fold has run, so an omission can at worst show a default in a summary line that does not exist yet (Q51).
Q14, with Q48 settling its edge. Each of the three fields gets its own orderComponents slot and joins isBare, so two rows of a group that disagree on either form variants for review rather than collapsing. "A row carrying only the default statuses" means a bare row, not a per-field absence: a defaults row that is non-bare for another reason still forms a variant, exactly as an empty genreTags behaves.
The price is stated in the design's contracts: if isBare failed to compare all three, every work would be non-bare, redirectWork's fast path would never fire, and silent duplicate healing would stop library-wide with no compiler error.
Q13, refined by Q38 and Q41. The target keeps its statuses and verdict, mirroring how it keeps its type; a "most advanced value" rule was rejected because two differing non-default values need a tie-break. A source status is listed as discarded only when it is non-default and differs — a default contributes nothing (Q14) and agreement is not a discard. A source verdict is reader text, so a differing non-blank one is appended to the merge audit block as source notes are, and WorkMergeField.recordedInNotes lets the preview say which discarded fields are recorded and which are dropped.
Q36, with Q54 and Q23. Names, symbols, hues, dimension-qualified labels and control identifiers live in Asterism/Asterism/Views/WorkStatusPresentation.swift, beside WorkTypePresentation.swift. Rating's private view-helper labels are named in the log as the pattern not to copy, because four surfaces need the same spelling here. Every label is qualified ("Work: Finished", "Reading: Finished") because both enums have a value called finished and an unqualified pill row would read "Finished, Finished". DuplicateResolutionView's temporary second copy was deliberately time-boxed to one task and then deleted (Q54).
Q33 and Q55. updateWork trims beside normalizeTags; import writes the archive value verbatim, so an archive this build wrote round-trips exactly and a second trim site never becomes a second policy. Q63 follows from it: hasUnsavedChanges compares the verdict draft untrimmed, like the title, because the reload after a save re-seeds the trimmed text and no phantom change survives.
The working-tree pre-push-review fixes complete the picture at the read sites rather than adding a trim: the detail paragraph, the reconciler's propagation guard and the fold now all test M2Unicode.isBlank, so a whitespace-only verdict is treated as absent wherever it would be shown, propagated or recorded.
Design's choke-point table, on the genreTags shape: DuplicateReconciler.apply writes a status to a sibling row only when the carrier's value is non-default (non-blank for the verdict) and differs. A carrier on reading must never overwrite a sibling's abandoned, because rows arrive from CloudKit in arbitrary order. There is deliberately no propagates gate as the type arm has — that gate exists only because a type may be .removed, a state a closed three-value vocabulary has no counterpart for.
Q31, with the requirements' Non-Goals. The partition is two concatenated filter calls at the end of WorksSort.apply; partition(by:) is unstable and is not used. Because that step and the view's split-by-emptiness are both stable, "last within each section under every sort" follows from one line and no section-aware code. It is not offered as a reader-selectable sort, and it does not apply to Recent (Q11), which is a chronological entry feed reordering would make meaningless. The merge picker does not go through WorksSort and keeps its order (Q22).
Q30. WorksFilter gains two nil-defaulted properties, but pruned(to:) leaves both alone and WorksFilterOptions carries no entry for them — the menu iterates allCases. Pruning exists because an open vocabulary (a tag, a hostname) can vanish from the snapshot; a closed three-value enum cannot, and Req 6.3 requires every value to be offered and to stay selected whether or not any work carries it.
Q29. The id hashes orderComponents, which gained three parts and also feed the representative ordering, so every existing Work variant id changes and a group's carrier may move. The ids live only in the in-memory settling ledger and in torn-write disclosures; a disclosure taken before the update reads as disclosureStale afterwards and the sheet re-presents once. This is the same cost configurable-work-types accepted for the type token.
Q58. Req 5.2 mandates a row-level knock-down and the removed-type pill already carries its own dimmedTypeTag, so a row that is both renders that pill at roughly a quarter opacity. Clamping would need a third opacity constant the style guide does not have. The UI fixture deliberately keeps the two cases on different works — Ashfall is the removed-type row and Marrow Lane the abandoned one — so the pairing is never on screen in a journey, and the owner looks at it once on a device (Q67 records the matching limit: XCUITest cannot read opacity at all).
Q66. At accessibility5 the finished-reading popover spans 366x758 of an 874 pt window, so declineConfirmationDialog's fixed (0.5, 0.55) fallback landed inside "Mark the work finished too": the dialog closed, waitUntilGone was satisfied, and the case passed green with both statuses written — the opposite of what Req 3.2 says cancelling does. The helper now computes a point in a margin the popover leaves, and only where the popover covers the old default point, so every existing caller taps exactly where it always did. The band above the popover is the status bar and never reaches the dimming layer.
| Severity | Area | Finding | Resolution |
|---|---|---|---|
| minor | DuplicateReconciler.swift fan-out | The three carrier status reads ran twice per row inside the per-row loop (five tolerant reads per row), against the file's own precedent of classifying carrier facts once. | Hoisted carriedWorkStatus / carriedReadingStatus / carriedVerdict beside carriedURLs. |
| minor | DuplicateReconciler.swift verdict guard | Verdict propagation gated on isEmpty while the fold and the sheet use M2Unicode.isBlank, so a whitespace-only verdict would propagate as authored. | Gate on !M2Unicode.isBlank(carriedVerdict). |
| minor | WorkDetailView.swift read-mode verdict | The read-mode Verdict paragraph gated on isEmpty; an imported whitespace verdict (import writes verbatim, Q55) rendered an empty block. | Gate on !M2Unicode.isBlank(work.verdict). |
| minor | WorksView.swift statusLabels | The row's accessibility labels used their own 'is marked' rule (!= .ongoing / isDone) while the glyph keyed off systemImage != nil; the two could drift. The helper was also static with no external caller. | Gate on the presentation table's systemImage and make the helper private. |
| minor | WorkDetailView.swift capsule identifiers | Segment identifiers were interpolated inline in the view while every other identifier scheme has a named owner; six UI-test literals depended on the unnamed spelling. | Added WorkStatusPresentation.controlIdentifier / ReadingStatusPresentation.controlIdentifier beside name and systemImage; spelling unchanged. |
| nit | WorkDetailView.swift verdict placeholder | The field's placeholder was the raw string "Verdict" under a caption reading the prompt, then overridden by accessibilityLabel(prompt). | Pass the prompt as the placeholder. |
| minor | BackupV9ArchiveTests.swift Req 8.2 | retiredGenerationsRefuseByVersion asserted the archive's pair but not the supported pair the requirement also demands; production already named both. | One more #expect on the supported pair. |
| minor | specs: implementation.md vs verification-run.md | Task 20's record was titled and shaped as a verification run but filed as implementation.md, unlike every sibling spec; CLAUDE.md and the overview pointed at it for performance numbers. | Renamed to verification-run.md, repointed CLAUDE.md, OVERVIEW, prerequisites, tasks, changelog; this review writes the real implementation.md. |
| minor | decision_log.md Q65 | Q65 (popover never draws Cancel; Req 3.1/10.2 unmet as written) was a Quick Decision although it names a rejected alternative and leaves two requirements open — the format says promote. | Promoted to Decision 2 (status proposed); Q65's rationale now reads 'promoted to Decision 2'. |
| nit | decision_log.md WorkMergeOutcome | WorkMergeOutcome's defaulted parameters were the one carrier whose default policy lived only in a code comment (Q40/Q47/Q52 cover the others). | Q69 added. |
| nit | verification-run.md owner checks | Req 9.4's first clause (a V9 build refusing a "10" store) is asserted by prerequisites.md but tested nowhere and absent from the owner checklist. | Added to the owner's device checks. |
| nit | WorkDetailModel.swift hasUnsavedChanges | Quality review suggested comparing the verdict draft trimmed. | Skipped: Q63 decided the untrimmed comparison deliberately (same as the title). |
| nit | UI test helpers | row(titled:) and enterEditMode() are duplicated across WorkDetailStatusUITests, WorksListOptionsUITests and WorkDetailActionsUITests; two WorkVariantChoice builders in the unit suites. | Skipped: test-only refactor, outside this review's no-test-changes rule; worth folding into UIJourneySupport later. |
| nit | WorksListOptions.swift partition | abandonedLast is the third stable two-filter partition in the list code (the .oldest arm and the section split predate the branch). | Skipped: a shared helper touches pre-existing code for a sub-millisecond, documented pattern. |
| nit | Performance drift | Efficiency review attributes the +3–6% on the capture-projection arms to the three wider orderComponents slots and columns; 0.176 s against a 250 ms ceiling. | Skipped: recorded in verification-run.md §4; not worth acting on. |
Source: local run at 2026-09-05T12:38:44+10:00 · snapshot 8624d222f477361a7cd9992123d3a4f276b455d1 (dirty working tree)
Baseline: none
Execution: passed · JUnit: 1 file · Coverage: 1 file · Baseline: absent
Coverage scope: every test in the repository
Totals: 2301 passed · 0 failed · 31 skipped · 0 errored · 0 flaky
Derived by declaration name, from the diff (no baseline run).
Aggregate diff coverage: 96% (3962 of 4117 measurable added lines).
Head 93.7% (77391 of 82571 lines)
104 of 147 changed files matched coverage data.
Asterism/Asterism/ViewModels/AppLibraryModel.swift — no candidateAsterism/Asterism/ViewModels/SettingsBackupModel.swift — no candidateAsterism/Asterism/ViewModels/WorkDetailModel.swift — no candidateAsterism/Asterism/ViewModels/WorksListOptions.swift — no candidateAsterism/Asterism/Views/DuplicateResolutionView.swift — no candidateAsterism/Asterism/Views/WorkDetailView.swift — no candidateAsterism/Asterism/Views/WorkMergeView.swift — no candidateAsterism/Asterism/Views/WorkStatusPresentation.swift — no candidateAsterism/Asterism/Views/WorksView.swift — no candidateAsterism/AsterismTests/DuplicateSurfaceTests.swift — no candidateAsterism/AsterismTests/Helpers/MockLibraryProvider.swift — no candidateAsterism/AsterismTests/Helpers/TestFixtures.swift — no candidateAsterism/AsterismTests/IntegrationSafetyNetTests.swift — no candidateAsterism/AsterismTests/SettingsBackupModelTests.swift — no candidateAsterism/AsterismTests/SettingsImportTests.swift — no candidateAsterism/AsterismTests/WorkDetailModelTests.swift — no candidateAsterism/AsterismTests/WorkMergeModelTests.swift — no candidateAsterism/AsterismTests/WorkStatusPresentationTests.swift — no candidateAsterism/AsterismTests/WorksFilterPresentationTests.swift — no candidateAsterism/AsterismTests/WorksListOptionsTests.swift — no candidateAsterism/AsterismTests/WorksRowPresentationTests.swift — no candidateAsterism/AsterismUITests/AccessibilityJourneyUITests.swift — no candidateAsterism/AsterismUITests/UIJourneySupport.swift — no candidateAsterism/AsterismUITests/WideLayoutUITests.swift — no candidateAsterism/AsterismUITests/WorkDetailStatusUITests.swift — no candidateAsterism/AsterismUITests/WorksListOptionsUITests.swift — no candidateCHANGELOG.md — no candidateCLAUDE.md — no candidatePackages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-9-10-golden.json — no candidatedocs/agent-notes/rule-wire-format.md — no candidatedocs/agent-notes/schema-migration.md — no candidatedocs/agent-notes/testing.md — no candidatedocs/asterism-design.md — no candidatedocs/asterism-style-guide.md — no candidatespecs/OVERVIEW.md — no candidatespecs/retire-migration-chain/library-graph-baseline.txt — no candidatespecs/work-and-reading-status/decision_log.md — no candidatespecs/work-and-reading-status/design.md — no candidatespecs/work-and-reading-status/prerequisites.md — no candidatespecs/work-and-reading-status/requirements.md — no candidatespecs/work-and-reading-status/tasks.md — no candidatespecs/work-and-reading-status/verification-run.md — no candidatespecs/work-and-reading-status/implementation.md — no candidateFiles that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 90d9d17b7a1d6a176ce7215b0d7fbe0cce50a71c.
Click to expand.
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 2873d12..6c5c471 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -148,8 +148,8 @@ public final class AppLibraryModel { /// The resolved configuration after successful bootstrap. private var resolvedConfiguration: LibraryConfiguration? private var repository: (any LibraryProviding)?- /// Retains the concrete repository for backup export (conforms to BackupV8SnapshotProviding).- private var backupRepository: (any BackupV8SnapshotProviding)?+ /// Retains the concrete repository for backup export (conforms to BackupV9SnapshotProviding).+ private var backupRepository: (any BackupV9SnapshotProviding)? /// A pre-bootstrap failure used to fail closed on invalid debug launch input. private let startupFailureMessage: String? /// Seeds only a fresh, explicit temporary configuration used by UI tests.@@ -1942,7 +1942,7 @@ public final class AppLibraryModel { guard let repo = backupRepository, let config = resolvedConfiguration else { return nil } let stagingDir = config.rootDirectory .appending(path: "Library/Caches/BackupExports")- let exporter = BackupV8Exporter(+ let exporter = BackupV9Exporter( repository: repo, stagingDirectory: stagingDir )@@ -2088,7 +2088,9 @@ public final class AppLibraryModel { typeAssignment: reloaded.typeDisplay.assignment, genreTags: reloaded.genreTags, genericNotes: "Ada keeps the lighthouse, and the crew call her Nightjar. "- + "Brede rows the tender out at dusk."))+ + "Brede rows the tender out at dusk.",+ workStatus: reloaded.workStatus, readingStatus: reloaded.readingStatus,+ verdict: reloaded.verdict)) Self.logger.debug("Seeded the character-extraction UI test fixture") } @@ -2099,16 +2101,26 @@ public final class AppLibraryModel { /// than by date, so each of the four sorts draws a different list and a /// journey can tell them apart: ///- /// | Work | Site | Captured | Type | Tags |- /// |---|---|---|---|---|- /// | Marrow Lane | alpha.test | first | — | mystery |- /// | Ashfall | beta.test | second | webtoon, then removed | — |- /// | Zephyr Court | alpha.test | third | novel | — |- /// | Quill Harbour | beta.test | no entries | — | — |+ /// | Work | Site | Captured | Type | Tags | Work status | Reading status | Verdict |+ /// |---|---|---|---|---|---|---|---|+ /// | Marrow Lane | alpha.test | first | — | mystery | ongoing | abandoned | yes |+ /// | Ashfall | beta.test | second | webtoon, then removed | — | hiatus | reading | — |+ /// | Zephyr Court | alpha.test | third | novel | — | finished | finished | yes |+ /// | Quill Harbour | beta.test | no entries | — | — | ongoing | reading | — | /// /// Plus one unattached entry, so the group a filter hides (Q9) is on screen /// before the filter is chosen. ///+ /// `work-and-reading-status` gives the same four works their statuses, so+ /// the one scenario drives the ordering, dimming, glyph, filter and detail+ /// journeys as well as the sorts. **Marrow Lane is the abandoned one and+ /// Ashfall is not** (Q58): Ashfall already wears the removed-type pill's own+ /// knock-down, and a row wearing both dims twice — a pairing the owner's+ /// device check looks at once, not a state every journey should be reading+ /// through. Zephyr Court is finished on both sides, so it is the row wearing+ /// two glyphs; Quill Harbour stays on the defaults, so an unmarked row is+ /// still in the fixture.+ /// /// **The three works are seeded one whole work at a time**, rather than /// three captures and then three moves. `works()` orders on the newest /// entry's `lastSharedAt`, which the clock quantizes to a millisecond and@@ -2128,13 +2140,18 @@ public final class AppLibraryModel { try await seedWorksOptionsWork( title: "Marrow Lane", hostname: "alpha.test", slug: "marrow",- type: .none, tags: ["mystery"], in: repo)+ type: .none, tags: ["mystery"],+ workStatus: .ongoing, readingStatus: .abandoned,+ verdict: "Put it down when the fog plot stopped going anywhere.", in: repo) try await seedWorksOptionsWork( title: "Ashfall", hostname: "beta.test", slug: "ashfall",- type: .configured(webtoon.id), tags: [], in: repo)+ type: .configured(webtoon.id), tags: [],+ workStatus: .hiatus, readingStatus: .reading, verdict: "", in: repo) try await seedWorksOptionsWork( title: "Zephyr Court", hostname: "alpha.test", slug: "zephyr",- type: .configured(novel.id), tags: [], in: repo)+ type: .configured(novel.id), tags: [],+ workStatus: .finished, readingStatus: .finished,+ verdict: "A tidy ending, and the court scenes earned it.", in: repo) // After the assignment, so Ashfall keeps displaying a type the picker // no longer offers — the `.removed` display the dimmed filter row is@@ -2152,9 +2169,15 @@ public final class AppLibraryModel { /// One work of that fixture: a capture, a work on the same site, the move /// that puts one under the other, and the metadata the filters read.+ ///+ /// The statuses ride on the same final `updateWork` as the type and the+ /// tags rather than a second write, so the work is finished before the next+ /// capture starts and the date order stays deterministic. private func seedWorksOptionsWork( title: String, hostname: String, slug: String,- type: WorkTypeAssignment, tags: [String], in repo: LibraryRepository+ type: WorkTypeAssignment, tags: [String],+ workStatus: WorkStatus, readingStatus: ReadingStatus, verdict: String,+ in repo: LibraryRepository ) async throws { let entry = try await repo.capture(CaptureDraft( captureTitle: "Chapter 1 - \(title)",@@ -2173,7 +2196,8 @@ public final class AppLibraryModel { id: work.id, basis: WorkEditBasis(work: reloaded), draft: WorkMetadataDraft(- displayTitle: title, typeAssignment: type, genreTags: tags, genericNotes: ""))+ displayTitle: title, typeAssignment: type, genreTags: tags, genericNotes: "",+ workStatus: workStatus, readingStatus: readingStatus, verdict: verdict)) } /// Preserves two captures in the disposable root's spool, exactly as the
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex de27999..861a87b 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -5,19 +5,20 @@ import OSLog // MARK: - Backup Exporting Protocol /// Test seam abstracting the exporter's operations needed by the Settings-/// surface. Conforms `BackupV8Exporter` to this protocol via extension below.+/// surface. Conforms `BackupV9Exporter` to this protocol via extension below. ///-/// Settings exports 8/9 (`rule-citation-by-uuid` Req 5.1): the archive carries a-/// Work's site memberships, the reader's dismissed pairs, and citations that-/// name a rule by UUID alone. It is also the only format the app reads, so there-/// is one exporter and one importer.+/// Settings exports 9/10 (`work-and-reading-status` Req 8.1): the archive+/// carries a Work's site memberships, the reader's dismissed pairs, citations+/// that name a rule by UUID alone, and the Work's two statuses and verdict. It+/// is also the only format the app reads, so there is one exporter and one+/// importer. public protocol BackupExporting: Sendable {- func export(metadata: BackupV8Metadata) async throws -> BackupExportResult+ func export(metadata: BackupV9Metadata) async throws -> BackupExportResult func cleanup(_ result: BackupExportResult) func scavengeStaleFiles() } -extension BackupV8Exporter: BackupExporting {}+extension BackupV9Exporter: BackupExporting {} // MARK: - Settings Backup View Model @@ -78,7 +79,7 @@ public final class SettingsBackupModel { currentResult = nil do {- let metadata = BackupV8Metadata(+ let metadata = BackupV9Metadata( appBuild: Self.currentAppBuild(), exportedAt: Date() )@@ -91,7 +92,7 @@ public final class SettingsBackupModel { state = .failed // Privacy-safe: log only the error category, never user content errorMessage = Self.privacySafeMessage(for: error)- if let exportError = error as? BackupV8ExportError,+ if let exportError = error as? BackupV9ExportError, case .tornGroups = exportError { routesToCheckLibrary = true }@@ -139,7 +140,7 @@ public final class SettingsBackupModel { switch error { case is BackupCodecError: "Backup export failed due to an encoding error. Please try again."- case let error as BackupV8ExportError:+ case let error as BackupV9ExportError: exportMessage(for: error) default: "Backup export failed. Please try again."@@ -163,7 +164,7 @@ public final class SettingsBackupModel { /// (`character-extraction` Req 6.5, Q105). It needs no new sentence: the /// payload carries a count and a route, not a record kind, and Check Library /// is where every torn group is resolved.- private static func exportMessage(for error: BackupV8ExportError) -> String {+ private static func exportMessage(for error: BackupV9ExportError) -> String { switch error { case .tornGroups(let payload): tornGroupsMessage(payload)@@ -203,7 +204,7 @@ public final class SettingsBackupModel { switch error { case let e as BackupCodecError: "codec: \(e)"- case let e as BackupV8ExportError:+ case let e as BackupV9ExportError: "export: \(e)" case let e as LibraryRepositoryError: "repository: \(e)"
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex 365a748..998ffc7 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -179,6 +179,26 @@ public final class WorkDetailModel { public var draftTags: [String] = [] public var draftNotes: String = "" + // MARK: - The two statuses and the verdict (`work-and-reading-status`)++ /// The work's own side, as the capsule shows it (Req 1.2).+ ///+ /// `private(set)`: both status drafts move through their setters below,+ /// because the transitions carry rules — the finished-reading prompt on one+ /// side and Req 3.3's auto-revert on the other. A raw property write from a+ /// control would step past both.+ public private(set) var draftWorkStatus: WorkStatus = .ongoing+ /// The reader's side (Req 2.2).+ public private(set) var draftReadingStatus: ReadingStatus = .reading+ /// The verdict, always written whatever the reading status is (Req 2.5): a+ /// verdict typed and then hidden by a status change is still the reader's+ /// text, and an auto-revert must not erase it.+ public var draftVerdict: String = ""+ /// Whether *this* session's work-status change is what moved the reading+ /// status off `finished` (Req 3.3). Q39 undoes it in full when the work goes+ /// back to `finished`; any other reading change clears it.+ public private(set) var autoRevertedReading = false+ // Confirmed Work URL state is independent from metadata submission so a // failed URL operation does not discard unrelated in-progress edits. public var draftWorkURL: String = ""@@ -233,6 +253,10 @@ public final class WorkDetailModel { typeOptions = await pickerOptions(carrying: snapshot.typeDisplay) draftTags = snapshot.genreTags draftNotes = snapshot.genericNotes+ draftWorkStatus = snapshot.workStatus+ draftReadingStatus = snapshot.readingStatus+ draftVerdict = snapshot.verdict+ autoRevertedReading = false adoptCharacterDrafts(detail.characters) // Before the projection below, which is *about* the selected site. resetWorkURLHostnameIfNeeded()@@ -556,6 +580,12 @@ public final class WorkDetailModel { || draftAssignment != work.typeDisplay.assignment || draftTags != work.genreTags || draftNotes != work.genericNotes+ || draftWorkStatus != work.workStatus+ || draftReadingStatus != work.readingStatus+ // Compared whatever the reading status is (Req 2.5): a verdict the+ // reader typed before hiding the field is a change the checkmark has+ // to be offered for.+ || draftVerdict != work.verdict } // MARK: - View mode and edit mode (Decision 5)@@ -613,6 +643,12 @@ public final class WorkDetailModel { /// the URL after it would be committing a draft that had just been /// overwritten. public func commitEditing() async {+ // Req 3.4, and **first**: ahead of the URL step and every other write,+ // so a cancel at the dialog leaves every draft exactly as it was (Q37).+ if let prompt = finishedReadingCommitPrompt() {+ finishedReadingPrompt = prompt+ return+ } var committedURL = false if hasUnsavedWorkURLChange { guard await commitWorkURLDraft() else { return }@@ -649,9 +685,111 @@ public final class WorkDetailModel { draftAssignment = work.typeDisplay.assignment draftTags = work.genreTags draftNotes = work.genericNotes+ draftWorkStatus = work.workStatus+ draftReadingStatus = work.readingStatus+ draftVerdict = work.verdict+ // The session that could have auto-reverted is over.+ autoRevertedReading = false+ finishedReadingPrompt = nil adoptCharacterDrafts(presentation?.characters ?? []) } + // MARK: - The finished-reading rule (Decision 1, Reqs 3.1–3.5)++ /// What the "Mark as finished?" dialog is offering, or nil when none is up.+ ///+ /// Built as a **presented value** rather than a flag beside the model, on+ /// `WorkDeletionPrompt`'s shape and for its reason (Q37): SwiftUI runs a+ /// dialog's `isPresented` setter — the dismissal, which clears this — before+ /// it runs the tapped button's action, so `thenCommits` has to travel on the+ /// value the dialog rendered or the follow-through silently stops happening.+ public private(set) var finishedReadingPrompt: FinishedReadingPrompt?++ /// Req 3.1's transition: `finished` reading on a work that is not itself+ /// finished asks instead of writing, and the draft stays where it was, so+ /// the capsule keeps showing the previous value while the dialog is up.+ ///+ /// Re-selecting the value the draft already holds is a no-op, which is what+ /// keeps a *stored* violating pair (Req 3.5) from raising the dialog by+ /// being tapped.+ public func setDraftReadingStatus(_ status: ReadingStatus) {+ guard status != draftReadingStatus else { return }+ guard status != .finished || draftWorkStatus == .finished else {+ finishedReadingPrompt = FinishedReadingPrompt(id: workID, thenCommits: false)+ return+ }+ draftReadingStatus = status+ // Q39: the restore is only ever the undo of *this* session's revert. A+ // reading status the reader chose afterwards is theirs.+ autoRevertedReading = false+ }++ /// Req 3.3's transition, and Q39's undo of it. Neither raises a dialog: the+ /// revert is the rule keeping `finished` meaningful, and the restore is the+ /// mis-tap being taken back.+ public func setDraftWorkStatus(_ status: WorkStatus) {+ guard status != draftWorkStatus else { return }+ let previous = draftWorkStatus+ draftWorkStatus = status+ if previous == .finished, draftReadingStatus == .finished {+ // Req 2.5: the verdict text stays; only the field goes.+ draftReadingStatus = .reading+ autoRevertedReading = true+ return+ }+ if status == .finished, autoRevertedReading, draftReadingStatus == .reading {+ draftReadingStatus = .finished+ autoRevertedReading = false+ }+ }++ /// Req 3.4's gate (Q20): the pair is checked at the commit as well as at the+ /// pickers, because a work-status edit over a stored violating pair reaches+ /// neither transition. Gated on the draft pair *differing* from the stored+ /// one, so committing Req 3.5's stored violation unchanged writes it back+ /// rather than interrogating the reader about a state they did not create.+ private func finishedReadingCommitPrompt() -> FinishedReadingPrompt? {+ guard let work,+ draftReadingStatus == .finished,+ draftWorkStatus != .finished,+ draftWorkStatus != work.workStatus || draftReadingStatus != work.readingStatus+ else { return nil }+ return FinishedReadingPrompt(id: workID, thenCommits: true)+ }++ /// The dialog's dismissal, and its Cancel. Nothing is restored because+ /// nothing was changed — the transitions above leave the drafts alone until+ /// the reader answers.+ public func cancelFinishedReadingPrompt() {+ finishedReadingPrompt = nil+ }++ /// The reader's answer, over the prompt the dialog rendered.+ ///+ /// The prompt is a parameter rather than a property read, for+ /// `chooseDeletion(_:disposition:)`'s reason: the dismissal has already+ /// cleared the property by the time this runs.+ ///+ /// `async` because of the follow-through — a prompt raised at the commit+ /// resumes it, and the second `commitEditing()` now passes the check. When+ /// that commit stops in the URL step, the resolved statuses stay in the+ /// drafts and the editor stays open, exactly as any other URL refusal+ /// leaves the drafts beside it.+ public func resolveFinishedReadingPrompt(+ _ prompt: FinishedReadingPrompt, choosing resolution: FinishedReadingResolution+ ) async {+ finishedReadingPrompt = nil+ switch resolution {+ case .markWorkFinished:+ draftWorkStatus = .finished+ draftReadingStatus = .finished+ case .abandonInstead:+ draftReadingStatus = .abandoned+ }+ autoRevertedReading = false+ if prompt.thenCommits { await commitEditing() }+ }+ // MARK: - The character edit session (Reqs 3.2, 3.7, 5.3) /// Whether the reader has a character edit session in progress — staged@@ -886,14 +1024,29 @@ public final class WorkDetailModel { displayTitle: draftTitle, typeAssignment: draftAssignment, genreTags: draftTags,- genericNotes: draftNotes+ genericNotes: draftNotes,+ // What the capsules show and what the field holds (Q40).+ // `updateWork` writes all three to every row (Req 7.1), so a+ // draft carrying anything but the reader's own values would+ // write their change away with nothing to see.+ workStatus: draftWorkStatus,+ readingStatus: draftReadingStatus,+ verdict: draftVerdict ) let basis = work.map(WorkEditBasis.init(work:)) ?? WorkEditBasis( displayTitle: draftTitle, typeAssignment: draftAssignment, genreTags: draftTags, genericNotes: draftNotes, memberships: [],- lastParsedTitle: nil, titleProvenance: .manual)+ lastParsedTitle: nil, titleProvenance: .manual,+ // Stated rather than left to the parameter defaults (Q47),+ // from the same drafts the draft above sends — as every+ // other field of this fallback is. A basis quietly holding+ // `.ongoing`/`.reading`/`""` here would refuse every write+ // on a marked work.+ workStatus: draftWorkStatus,+ readingStatus: draftReadingStatus,+ verdict: draftVerdict) let outcome = try await library.updateWork(id: workID, basis: basis, draft: draft) if case .conflict(let conflict) = outcome { // No optimistic mutation and no draft reset: the edit is the@@ -1210,6 +1363,41 @@ public final class WorkDetailModel { } } +/// What the "Mark as finished?" dialog is offering (Req 3.1, Decision 1).+///+/// `WorkDeletionPrompt`'s shape, and for its reason: the value travels to the+/// dialog through `presenting:` and back into the model as a parameter, because+/// SwiftUI runs the dismissal before the tapped button's action.+///+/// `thenCommits` is the one thing the two arms differ by: the prompt raised at a+/// picker leaves the reader in edit mode, and the one raised at the commit+/// resumes the commit the reader had already asked for.+public struct FinishedReadingPrompt: Identifiable, Equatable, Sendable {+ public let id: UUID+ public let thenCommits: Bool++ /// The sentence above the three choices. Wording lives on the value, per the+ /// house convention that models own text and views own layout.+ public var message: String {+ "Finished reading means you read the whole work, and this one is not "+ + "marked finished. "+ + (thenCommits+ ? "Nothing has been saved yet."+ : "Choose what to record.")+ }+}++/// The two answers Req 3.1 offers that *change* something, and nothing else.+///+/// Cancel is not a case here, on `WorkDeletionDisposition`'s shape and for its+/// reason: the dialog's Cancel and its dismissal both go through+/// `cancelFinishedReadingPrompt()`, which is synchronous and has nothing to+/// resolve. A `.cancel` case would only be a second spelling of that call.+public enum FinishedReadingResolution: Equatable, Sendable {+ case markWorkFinished+ case abandonInstead+}+ /// What the Work URL section still shows. /// /// Q57 retired `work-detail-url-save` and `work-detail-url-clear` with the
diff --git a/Asterism/Asterism/ViewModels/WorksListOptions.swift b/Asterism/Asterism/ViewModels/WorksListOptions.swiftindex 9cb9328..06e5609 100644--- a/Asterism/Asterism/ViewModels/WorksListOptions.swift+++ b/Asterism/Asterism/ViewModels/WorksListOptions.swift@@ -68,13 +68,35 @@ enum WorksSort: String, CaseIterable, Identifiable, Sendable { } } + /// The ordering, then `work-and-reading-status` Req 5.3's partition.+ ///+ /// The partition runs last, over whatever the ordering produced, so the rule+ /// costs one line rather than one per sort. `WorksView` then splits the+ /// result by emptiness with `filter`; both steps are stable, so each section+ /// ends with its abandoned works in the active sort's order — under+ /// `.oldest`'s per-section reversal included (Q31).+ func apply(to works: [WorkSnapshot]) -> [WorkSnapshot] {+ Self.abandonedLast(ordered(works))+ }++ /// Abandoned works after everything else, each group in the order it was+ /// handed (Req 5.3).+ ///+ /// Two `filter` calls concatenated rather than `partition(by:)`: the+ /// standard library's partition is **not** stable, so it would scramble the+ /// order the sort above just established.+ private static func abandonedLast(_ works: [WorkSnapshot]) -> [WorkSnapshot] {+ works.filter { $0.readingStatus != .abandoned }+ + works.filter { $0.readingStatus == .abandoned }+ }+ /// The ordering itself, over the repository's order. /// /// Oldest first reverses each section separately, so the empty works stay /// behind the non-empty ones while their own `modifiedAt` order and id /// tie-break reverse with the rest (Req 1). The title sorts order everything /// together, because they draw one section.- func apply(to works: [WorkSnapshot]) -> [WorkSnapshot] {+ private func ordered(_ works: [WorkSnapshot]) -> [WorkSnapshot] { switch self { case .newest: return works@@ -131,24 +153,38 @@ enum WorksTypeSelection: Hashable, Sendable { } } -/// The Works list's three single-value filters (Req 5, Q3).+/// The Works list's five single-value filters (Req 5, Q3;+/// `work-and-reading-status` Req 6.1). ///-/// One value per dimension, ANDed across the three and with the search query.-/// View state with the query's lifetime rather than a stored preference (Q5): a-/// sort is a preference a reader sets once, a filter is a question they are-/// asking now.+/// One value per dimension, ANDed across them and with the search query. View+/// state with the query's lifetime rather than a stored preference (Q5): a sort+/// is a preference a reader sets once, a filter is a question they are asking+/// now.+///+/// The two status dimensions differ from the other three in one way only: their+/// vocabularies are closed, so they carry nothing in `WorksFilterOptions` and+/// `pruned(to:)` never touches them (Q30). struct WorksFilter: Equatable, Sendable { var type: WorksTypeSelection? var tag: String? var hostname: String?+ var workStatus: WorkStatus?+ var readingStatus: ReadingStatus? - init(type: WorksTypeSelection? = nil, tag: String? = nil, hostname: String? = nil) {+ init(+ type: WorksTypeSelection? = nil, tag: String? = nil, hostname: String? = nil,+ workStatus: WorkStatus? = nil, readingStatus: ReadingStatus? = nil+ ) { self.type = type self.tag = tag self.hostname = hostname+ self.workStatus = workStatus+ self.readingStatus = readingStatus } - var isActive: Bool { type != nil || tag != nil || hostname != nil }+ var isActive: Bool {+ type != nil || tag != nil || hostname != nil || workStatus != nil || readingStatus != nil+ } func apply(to works: [WorkSnapshot]) -> [WorkSnapshot] { guard isActive else { return works }@@ -160,6 +196,10 @@ struct WorksFilter: Equatable, Sendable { /// this the picker holds a selection none of its rows carry, draws no /// checkmark anywhere in that section, and the reader's only way out is /// Clear.+ ///+ /// The two statuses are deliberately absent (Q30): a closed vocabulary+ /// cannot stop being offered, and Req 6.3 wants a status value selectable+ /// and kept whether or not any work currently carries it. func pruned(to options: WorksFilterOptions) -> WorksFilter { var pruned = self if let type, !options.types.contains(where: { $0.selection == type }) { pruned.type = nil }@@ -180,6 +220,11 @@ struct WorksFilter: Equatable, Sendable { if let hostname, !work.memberships.contains(where: { $0.hostname == hostname }) { return false }+ // Compared against the *resolved* statuses, so a stored spelling this+ // build does not know is filtered as the default it reads as+ // (Reqs 1.3, 2.7).+ if let workStatus, work.workStatus != workStatus { return false }+ if let readingStatus, work.readingStatus != readingStatus { return false } return true } }@@ -284,14 +329,25 @@ struct WorksFilterOptions: Equatable, Sendable { /// the values are `Sendable`: pure functions of value types. nonisolated enum WorksFilterPresentation { - /// The active values in menu order — type, then tag, then site — spelled as- /// the pickers spell them. A type is named by the option it came from, so a- /// merged type's pill wears the same first-seen spelling its menu row does.+ /// The active values in menu order — type, tag, site, work status, reading+ /// status — spelled as the pickers spell them. A type is named by the option+ /// it came from, so a merged type's pill wears the same first-seen spelling+ /// its menu row does.+ ///+ /// The two statuses are named **with their dimension** (Q23): both have a+ /// value called "Finished", and a pill row reading "Finished, Finished"+ /// would tell the reader nothing about what is narrowing their list. static func activeLabels(_ filter: WorksFilter, options: WorksFilterOptions) -> [String] { var labels: [String] = [] if let type = filter.type { labels.append(options.name(for: type)) } if let tag = filter.tag { labels.append(tag) } if let hostname = filter.hostname { labels.append(hostname) }+ if let workStatus = filter.workStatus {+ labels.append(WorkStatusPresentation.accessibilityLabel(workStatus))+ }+ if let readingStatus = filter.readingStatus {+ labels.append(ReadingStatusPresentation.accessibilityLabel(readingStatus))+ } return labels } @@ -330,4 +386,19 @@ nonisolated enum WorksFilterPresentation { static func siteRowIdentifier(_ hostname: String) -> String { "works-filter-site-\(hostname)" }++ // MARK: - The two status dimensions (`work-and-reading-status` Req 6.1)++ static let anyWorkStatusRowIdentifier = "works-filter-work-status-any"+ static let anyReadingStatusRowIdentifier = "works-filter-reading-status-any"++ /// Keyed by the raw value, which is the stored spelling and is frozen — so+ /// a UI test addressing a status row is addressing the column itself.+ static func workStatusRowIdentifier(_ status: WorkStatus) -> String {+ "works-filter-work-status-\(status.rawValue)"+ }++ static func readingStatusRowIdentifier(_ status: ReadingStatus) -> String {+ "works-filter-reading-status-\(status.rawValue)"+ } }
diff --git a/Asterism/Asterism/Views/DuplicateResolutionView.swift b/Asterism/Asterism/Views/DuplicateResolutionView.swiftindex 002edfa..ccc8611 100644--- a/Asterism/Asterism/Views/DuplicateResolutionView.swift+++ b/Asterism/Asterism/Views/DuplicateResolutionView.swift@@ -273,6 +273,30 @@ struct DuplicateResolutionView: View { .lineLimit(1) .foregroundStyle(.secondary) }+ // V10 (Req 7.2): a status the copies disagree on is part of the+ // decision, so it is on the row that the reader picks.+ if let line = Self.workStatusLine(variant, differing: model.differingFields) {+ Text(line)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("duplicate-variant-work-status")+ }+ if let line = Self.readingStatusLine(variant, differing: model.differingFields) {+ Text(line)+ .font(.caption)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("duplicate-variant-reading-status")+ }+ }+ // Q21: shown whatever the reading status. Q7 hides a verdict under+ // `reading` everywhere else, and two copies differing by nothing but+ // that text would otherwise be indistinguishable here.+ if let verdict = Self.verdictLine(variant, differing: model.differingFields) {+ Text(verdict)+ .font(.caption)+ .lineLimit(3)+ .foregroundStyle(.secondary)+ .accessibilityIdentifier("duplicate-variant-verdict") } Text(variant.firstCapturedAt, style: .date) .font(.caption2)@@ -281,6 +305,45 @@ struct DuplicateResolutionView: View { } } + // MARK: - The Work variant's status arms (V10 Req 7.2)++ /// The three arms are pure functions of the variant and the differing set so+ /// the words a reader chooses copies by can be pinned without a screen —+ /// `WorkMergeView.identityLine`'s shape.+ ///+ /// Both statuses have a value called "finished", and the row prints bare+ /// names, so each line names its dimension (Q23) — which is exactly what+ /// `WorkStatusPresentation.accessibilityLabel` spells for the row glyphs and+ /// the filter pills. The words were duplicated here only until that table+ /// landed (Q54); this sheet is its fourth consumer, not a second table.+ static func workStatusLine(+ _ variant: WorkVariantChoice, differing: [DuplicateResolutionField]+ ) -> String? {+ guard differing.contains(.workStatus) else { return nil }+ return WorkStatusPresentation.accessibilityLabel(variant.workStatus)+ }++ static func readingStatusLine(+ _ variant: WorkVariantChoice, differing: [DuplicateResolutionField]+ ) -> String? {+ guard differing.contains(.readingStatus) else { return nil }+ return ReadingStatusPresentation.accessibilityLabel(variant.readingStatus)+ }++ /// Labelled like the two status arms, and for the same reason: the line sits+ /// directly under the generic notes, and a bare run of reader prose there is+ /// indistinguishable from another copy's notes.+ ///+ /// Blank rather than `isEmpty`: the fold decides a verdict is worth carrying+ /// with `M2Unicode.isBlank`, so a verdict of nothing but whitespace has to+ /// read as absent on the sheet too.+ static func verdictLine(+ _ variant: WorkVariantChoice, differing: [DuplicateResolutionField]+ ) -> String? {+ guard differing.contains(.verdict), !M2Unicode.isBlank(variant.verdict) else { return nil }+ return "Verdict: \(variant.verdict)"+ }+ private func ratingLabel(_ rating: Rating?) -> String { switch rating { case .up: "▲"
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex b1b1c6f..e55bf14 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -317,6 +317,10 @@ struct WorkDetailView: View { "This work stops being on \(hostname). Nothing else changes — it " + "has no notes from there, and its other sites keep theirs.") }+ // Req 3.1's three actions, in a modifier of their own: the `List` above+ // already carries five presentations, and one more inline put the+ // builder past what the type checker will solve in reasonable time.+ .finishedReadingDialog(model) } // MARK: - View mode@@ -388,6 +392,26 @@ struct WorkDetailView: View { .accessibilityIdentifier("work-detail-tags") } + // Req 4.2: the reader's closing thought, between the tags and+ // the work's own notes. Shown only where there is one to show —+ // Req 2.5 keeps a stored verdict but hides it while the reader+ // is back on `reading`.+ if work.readingStatus.isDone, !M2Unicode.isBlank(work.verdict) {+ VStack(alignment: .leading, spacing: 4) {+ Text("Verdict")+ .font(.caption)+ .foregroundStyle(AsterismColors.secondaryText)+ Text(work.verdict)+ .font(.subheadline)+ .foregroundStyle(AsterismColors.noteText)+ .lineSpacing(4)+ .fixedSize(horizontal: false, vertical: true)+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .accessibilityElement(children: .contain)+ .accessibilityIdentifier("work-detail-verdict")+ }+ // The work's own notes read as the lede of the page: a // paragraph, no card and no section header of its own. Absent // when there are none — an empty field on a read screen invites@@ -492,6 +516,28 @@ struct WorkDetailView: View { .foregroundStyle(AsterismColors.violet) .accessibilityLabel("\(model.pulse.down) rated down") .accessibilityIdentifier("work-detail-pulse-down")++ // Req 4.1: the two statuses as further items on the same line, each+ // a glyph with its name. A status on its default adds nothing —+ // which is what the presentation table's nil symbol says — so the+ // line is unchanged for the works the reader has said nothing about.+ let workStatus = model.work?.workStatus ?? .ongoing+ if let symbol = WorkStatusPresentation.systemImage(workStatus) {+ Label(WorkStatusPresentation.name(workStatus), systemImage: symbol)+ .font(.caption)+ .foregroundStyle(WorkStatusPresentation.hue(workStatus))+ .accessibilityLabel(WorkStatusPresentation.accessibilityLabel(workStatus))+ .accessibilityIdentifier("work-detail-status-work")+ }+ let readingStatus = model.work?.readingStatus ?? .reading+ if let symbol = ReadingStatusPresentation.systemImage(readingStatus) {+ Label(ReadingStatusPresentation.name(readingStatus), systemImage: symbol)+ .font(.caption)+ .foregroundStyle(ReadingStatusPresentation.hue(readingStatus))+ .accessibilityLabel(+ ReadingStatusPresentation.accessibilityLabel(readingStatus))+ .accessibilityIdentifier("work-detail-status-reading")+ } } .lineLimit(1) // `children: .contain` before the identifier, or the group's identifier@@ -538,6 +584,52 @@ struct WorkDetailView: View { .constellationCard() .constellationListRow() + // Reqs 1.2 and 2.2, in the order they are named. Both capsules+ // contain a segment called "Finished" and nothing else on the screen+ // says which is which, so each carries a visible caption rather than+ // relying on position (Q26 chose the capsule over a `Picker`: three+ // short labels are a state to see, not a menu to open).+ captionedCard("Work status") {+ ConstellationSegmentedControl(+ values: WorkStatus.allCases,+ selection: Binding(+ get: { model.draftWorkStatus },+ set: { model.setDraftWorkStatus($0) }),+ containerLabel: "Work status",+ title: WorkStatusPresentation.name,+ identifier: WorkStatusPresentation.controlIdentifier)+ }++ captionedCard("Reading status") {+ ConstellationSegmentedControl(+ values: ReadingStatus.allCases,+ selection: Binding(+ get: { model.draftReadingStatus },+ // Req 3.1: the setter is the transition, not an+ // assignment — choosing `finished` on an unfinished work+ // raises the dialog and leaves the capsule where it was.+ set: { model.setDraftReadingStatus($0) }),+ containerLabel: "Reading status",+ title: ReadingStatusPresentation.name,+ identifier: ReadingStatusPresentation.controlIdentifier)+ }++ // Req 2.3: present exactly while the draft reading status is done+ // reading, under the prompt that tells the two verdicts apart. The+ // table returns nil for `reading`, which *is* the absence.+ if let prompt = ReadingStatusPresentation.verdictPrompt(model.draftReadingStatus) {+ captionedCard(prompt) {+ TextField(prompt, text: $model.draftVerdict, axis: .vertical)+ .lineLimit(3...6)+ // Req 10.1: the caption is a sibling `Text`, so the+ // field carries the same prompt as its placeholder and+ // its spoken label, and the reader hears which verdict+ // is being asked for.+ .accessibilityLabel(prompt)+ .accessibilityIdentifier("work-detail-verdict-field")+ }+ }+ TextField( "Genre tags (comma-separated)", text: Binding(@@ -558,6 +650,27 @@ struct WorkDetailView: View { } } + /// One edit-mode card under a visible caption — the recipe the two status+ /// capsules and the verdict field share.+ ///+ /// The caption is a label rather than a placeholder for the verdict's reason+ /// (Q16): a placeholder vanishes the moment the reader types, and here it is+ /// the caption that says what the control is for.+ private func captionedCard(+ _ caption: String, @ViewBuilder content: () -> some View+ ) -> some View {+ VStack(alignment: .leading, spacing: 8) {+ Text(caption)+ .font(.caption.weight(.semibold))+ .foregroundStyle(AsterismColors.secondaryText)+ content()+ }+ .frame(maxWidth: .infinity, alignment: .leading)+ .padding(12)+ .constellationCard()+ .constellationListRow()+ }+ /// Req 5.3. Hidden entirely where the work has no entries — the repository /// decides that by returning no URL, so the screen never has to guess. @ViewBuilder@@ -1813,6 +1926,44 @@ private struct WorkChapterRowView: View { } } +// MARK: - The finished-reading dialog (Req 3.1, Decision 1)++extension View {+ /// Req 3.1's dialog, raised either by the reading-status capsule or by the+ /// checkmark (Req 3.4).+ ///+ /// The prompt travels to the buttons as `presenting:` and back into the+ /// model as a parameter (Q37): SwiftUI runs the dismissal — which clears the+ /// model's prompt — before the tapped button's action, so a commit+ /// follow-through read from the model would find nothing to follow.+ ///+ /// A modifier of its own rather than a sixth presentation inline on the+ /// screen's `List`: that expression was already at the edge of what the type+ /// checker will solve, and one more sent it over.+ fileprivate func finishedReadingDialog(_ model: WorkDetailModel) -> some View {+ confirmationDialog(+ "Mark as finished?",+ isPresented: Binding(+ get: { model.finishedReadingPrompt != nil },+ set: { if !$0 { model.cancelFinishedReadingPrompt() } }),+ presenting: model.finishedReadingPrompt+ ) { prompt in+ Button("Mark the work finished too") {+ Task { await model.resolveFinishedReadingPrompt(prompt, choosing: .markWorkFinished) }+ }+ .accessibilityIdentifier("work-detail-finished-mark-work")+ Button("Abandoned instead") {+ Task { await model.resolveFinishedReadingPrompt(prompt, choosing: .abandonInstead) }+ }+ .accessibilityIdentifier("work-detail-finished-abandon")+ Button("Cancel", role: .cancel) { model.cancelFinishedReadingPrompt() }+ .accessibilityIdentifier("work-detail-finished-cancel")+ } message: { prompt in+ Text(prompt.message)+ }+ }+}+ // MARK: - Sort order titles /// The two orders as the capsule offers them, stated beside the control that
diff --git a/Asterism/Asterism/Views/WorkMergeView.swift b/Asterism/Asterism/Views/WorkMergeView.swiftindex f26d0fa..d7cce1c 100644--- a/Asterism/Asterism/Views/WorkMergeView.swift+++ b/Asterism/Asterism/Views/WorkMergeView.swift@@ -129,8 +129,14 @@ struct WorkMergeView: View { } /// One sentence per candidate, naming what the row shows: the title, the- /// sites, the type where it has one, the note count, and why it cannot be- /// chosen where it cannot.+ /// sites, the type where it has one, the note count, its status marks, and+ /// why it cannot be chosen where it cannot.+ ///+ /// `work-and-reading-status` Req 5.4/Q32: the picker draws `WorkRow`, so its+ /// rows wear the same glyphs — and a picker row is one button too, so the+ /// clauses reach a reader who cannot see them only through this label. They+ /// sit before the refusal, which is a finished sentence of its own and has+ /// to stay the last thing the row says. static func destinationLabel(_ work: WorkSnapshot, unavailable: String?) -> String { var parts = ["Merge into \(work.displayTitle)"] if !work.memberships.isEmpty {@@ -138,8 +144,14 @@ struct WorkMergeView: View { } if let typeName = work.typeDisplay.name { parts.append(typeName) } parts.append(Pluralisation.count(work.entries.count, "note", "notes"))- if let unavailable { parts.append(unavailable) }- return parts.joined(separator: ", ")+ let clause = WorksRowPresentation.statusClause(for: work)+ let described = parts.joined(separator: ", ") + clause+ guard let unavailable else { return described }+ // The refusal is a finished sentence of its own, and so is a status+ // clause (Q32): a comma between them would splice two sentences+ // together. Without a clause the label is still mid-sentence, so it+ // keeps the comma it has always had.+ return described + (clause.isEmpty ? ", " : ". ") + unavailable } // MARK: - Confirmation Preview@@ -201,10 +213,10 @@ struct WorkMergeView: View { Image(systemName: "checkmark.circle") .foregroundStyle(AsterismColors.cyan) .accessibilityHidden(true)- Text(fieldLabel(field))+ Text(Self.fieldLabel(field)) } .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityLabel("Kept: \(fieldLabel(field))")+ .accessibilityLabel("Kept: \(Self.fieldLabel(field))") .accessibilityIdentifier("merge-retained-\(field.rawValue)") } }@@ -218,13 +230,17 @@ struct WorkMergeView: View { Image(systemName: "archivebox") .foregroundStyle(AsterismColors.amberText) .accessibilityHidden(true)- Text(fieldLabel(field))- Text("Recorded in merged notes")+ Text(Self.fieldLabel(field))+ // Req 7.3: which discarded fields the merged notes+ // record and which are simply dropped. One caption+ // for both was a false promise once a dropped status+ // could appear in this list.+ Text(Self.discardedCaption(field)) .font(.caption) .foregroundStyle(.secondary) } .frame(minHeight: AsterismLayout.minHitTarget)- .accessibilityLabel("Discarded: \(fieldLabel(field)), recorded in merged notes")+ .accessibilityLabel(Self.discardedRowLabel(field)) .accessibilityIdentifier("merge-discarded-\(field.rawValue)") } }@@ -244,7 +260,7 @@ struct WorkMergeView: View { .accessibilityHidden(true) Text("Notes will include merge record") }- .accessibilityLabel("Audit block: discarded values will be recorded in target notes")+ .accessibilityLabel(Self.auditBlockLabel) } .accessibilityIdentifier("merge-audit-disclosure") }@@ -304,7 +320,9 @@ struct WorkMergeView: View { // MARK: - Label Helpers - private func fieldLabel(_ field: WorkMergeField) -> String {+ /// Static and pure, like `identityLine`: the words a reader approves a merge+ /// on are pinned without a screen.+ static func fieldLabel(_ field: WorkMergeField) -> String { switch field { case .targetDisplayTitle: "Target title" case .sourceManualTitle: "Source manual title"@@ -315,9 +333,35 @@ struct WorkMergeView: View { case .sourceNotes: "Source notes" case .targetGenreTags: "Target tags" case .sourceGenreTags: "Source tags"+ case .targetWorkStatus: "Target work status"+ case .targetReadingStatus: "Target reading status"+ case .targetVerdict: "Target verdict"+ case .sourceWorkStatus: "Source work status"+ case .sourceReadingStatus: "Source reading status"+ case .sourceVerdict: "Source verdict" } } + /// What becomes of a discarded field (Req 7.3). The answer is the field's+ /// own, not the section's: a title, a Work URL, notes and a verdict are+ /// written into the merged notes, and a dropped status is not written+ /// anywhere.+ static func discardedCaption(_ field: WorkMergeField) -> String {+ field.recordedInNotes ? "Recorded in merged notes" : "Not carried over"+ }++ /// The discarded row's spoken sentence, composed here rather than at the+ /// call site so the two halves and the comma between them are one pinnable+ /// thing (Req 7.3).+ static func discardedRowLabel(_ field: WorkMergeField) -> String {+ "Discarded: \(fieldLabel(field)), \(discardedCaption(field).lowercased())"+ }++ /// The disclosure's label no longer claims the block holds *every* discarded+ /// value: since V10 the list can carry statuses, which it does not record.+ static let auditBlockLabel =+ "Audit block: the discarded values marked recorded in merged notes are added to the target's notes"+ private func issueLabel(_ issue: WorkMergeIssue) -> String { switch issue { case .reviewURLIdentity: "Review URL identity after merge"
diff --git a/Asterism/Asterism/Views/WorkStatusPresentation.swift b/Asterism/Asterism/Views/WorkStatusPresentation.swiftnew file mode 100644index 0000000..5514fc2--- /dev/null+++ b/Asterism/Asterism/Views/WorkStatusPresentation.swift@@ -0,0 +1,114 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// The one place a `WorkStatus` becomes words, a symbol and a hue (Q36).+///+/// Beside `WorkTypePresentation.swift`, and for its reason: four surfaces —+/// the Works row's glyph and label, the filter menu and its pills, the work+/// page's meta line, and the duplicate sheet's variant lines (Q54) — say the+/// same three things about a status, and a table each is four chances for them+/// to disagree in a way only a reader would notice.+///+/// `nonisolated` throughout: `WorksFilterPresentation` is a pure value-type+/// helper and reads these, so under this target's MainActor-by-default+/// isolation the whole table has to be reachable from outside the actor.+nonisolated enum WorkStatusPresentation {++ /// What the reader is told a status is called — the picker row, the meta+ /// line, and the second half of every label below.+ static func name(_ status: WorkStatus) -> String {+ switch status {+ case .ongoing: "Ongoing"+ case .finished: "Finished"+ case .hiatus: "On hiatus"+ }+ }++ /// The row's mark, or nil where there is nothing to mark (Reqs 4.3, 5.1):+ /// `ongoing` is the default every work starts on, and a glyph on every row+ /// would say nothing.+ ///+ /// Neither symbol is used anywhere else in the app, so neither drags an+ /// existing meaning in with it (Q27).+ static func systemImage(_ status: WorkStatus) -> String? {+ switch status {+ case .ongoing: nil+ case .finished: "flag.checkered"+ case .hiatus: "pause.circle"+ }+ }++ /// The type tag's violet (Q27): the work side of a row already speaks in+ /// violet, and the feature adds no third accent to the design language.+ ///+ /// Takes the value like every other entry in the table, so a call site+ /// reads one shape per column — the hue is per dimension today, and a+ /// signature that said so would have to change if it ever stopped being.+ static func hue(_ status: WorkStatus) -> Color { AsterismColors.violet }++ /// The words the glyph is worth to a reader who cannot see it — and,+ /// unchanged, the filter pill's own visible text and the duplicate sheet's+ /// variant line (Q23, Q54).+ ///+ /// Every value is named with its dimension because both enums have a value+ /// called "Finished": an unqualified pill row would read "Finished,+ /// Finished" and say nothing.+ static func accessibilityLabel(_ status: WorkStatus) -> String { "Work: \(name(status))" }++ /// The edit-mode capsule segment's identifier, spelled here beside the+ /// name so the journeys and the view read one table.+ static func controlIdentifier(_ status: WorkStatus) -> String {+ "work-detail-work-status-\(status.rawValue)"+ }+}++/// The same table for the reader's side of a work (Q36).+nonisolated enum ReadingStatusPresentation {++ static func name(_ status: ReadingStatus) -> String {+ switch status {+ case .reading: "Reading"+ case .finished: "Finished"+ case .abandoned: "Abandoned"+ }+ }++ /// Marked exactly where the verdict exists — `isDone` — because those are+ /// the two states the reader has left behind them. `reading` is the default+ /// and draws nothing (Reqs 4.3, 5.1, 5.2).+ static func systemImage(_ status: ReadingStatus) -> String? {+ switch status {+ case .reading: nil+ case .finished: "checkmark"+ case .abandoned: "book.closed"+ }+ }++ /// The count pill's cyan (Q27), for `WorkStatusPresentation.hue`'s reason:+ /// the reading side of a row already speaks in cyan.+ static func hue(_ status: ReadingStatus) -> Color { AsterismColors.cyan }++ static func accessibilityLabel(_ status: ReadingStatus) -> String {+ "Reading: \(name(status))"+ }++ /// The edit-mode capsule segment's identifier (see `WorkStatusPresentation`).+ static func controlIdentifier(_ status: ReadingStatus) -> String {+ "work-detail-reading-status-\(status.rawValue)"+ }++ /// The label above the verdict field (Req 2.3, Q16). It is the prompt that+ /// tells the two verdicts apart, so it is a label rather than a placeholder+ /// — a placeholder vanishes the moment the reader types.+ ///+ /// Nil exactly where the field is absent: a work still being read has no+ /// verdict to write yet.+ static func verdictPrompt(_ status: ReadingStatus) -> String? {+ switch status {+ case .reading: nil+ case .finished: "How was it?"+ case .abandoned: "Why did you stop?"+ }+ }+}
diff --git a/Asterism/Asterism/Views/WorksView.swift b/Asterism/Asterism/Views/WorksView.swiftindex 24cb6fd..c7e5b8d 100644--- a/Asterism/Asterism/Views/WorksView.swift+++ b/Asterism/Asterism/Views/WorksView.swift@@ -250,6 +250,25 @@ struct WorksView: View { tag: \.self, identifier: WorksFilterPresentation.siteRowIdentifier ) { Text($0) }++ // `work-and-reading-status` Req 6.1's two dimensions. They iterate+ // `allCases` rather than a slice of `filterOptions`: the+ // vocabularies are closed, so every value is offered whether or not+ // a work currently carries it (Req 6.3, Q30).+ filterPicker(+ "Work status", options: WorkStatus.allCases, selection: $filter.workStatus,+ anyIdentifier: WorksFilterPresentation.anyWorkStatusRowIdentifier,+ tag: \.self,+ identifier: WorksFilterPresentation.workStatusRowIdentifier+ ) { Text(WorkStatusPresentation.name($0)) }++ filterPicker(+ "Reading status", options: ReadingStatus.allCases,+ selection: $filter.readingStatus,+ anyIdentifier: WorksFilterPresentation.anyReadingStatusRowIdentifier,+ tag: \.self,+ identifier: WorksFilterPresentation.readingStatusRowIdentifier+ ) { Text(ReadingStatusPresentation.name($0)) } } label: { // Req 6: filled while any filter is active. The sort alone does not // change it — every list has a sort, so a permanently filled icon@@ -267,8 +286,8 @@ struct WorksView: View { /// One filter dimension: "Any" — the one selection that is not a value, so /// it cannot be drawn from the options — then every value the snapshot- /// offers. Stated once for the three dimensions so the "Any" row and the- /// identifiers cannot be spelled three ways.+ /// offers. Stated once for every dimension so the "Any" row and the+ /// identifiers cannot be spelled a different way per filter. private func filterPicker<Option: Hashable, Tag: Hashable, RowLabel: View>( _ title: String, options: [Option],@@ -576,8 +595,53 @@ enum WorksRowPresentation { /// rather than "from ", which is what a joined empty list would say. static func openLabel(for work: WorkSnapshot) -> String { let hostnames = work.memberships.map(\.hostname)- guard !hostnames.isEmpty else { return "Open Work \(work.displayTitle)" }- return "Open Work \(work.displayTitle) from \(hostnames.joined(separator: ", "))"+ let sited = hostnames.isEmpty+ ? "Open Work \(work.displayTitle)"+ : "Open Work \(work.displayTitle) from \(hostnames.joined(separator: ", "))"+ return sited + statusClause(for: work)+ }++ /// `work-and-reading-status` Req 5.5: what the row's glyphs are worth to a+ /// reader who cannot see them.+ ///+ /// `WorkRow` is one button, so VoiceOver never reaches a child element's+ /// own label — the row's single label is the marks' only channel, and the+ /// merge picker's row (Req 5.4) needs the same words through its own.+ ///+ /// Empty on a work at both defaults, so an unmarked row reads exactly as it+ /// did before the statuses existed.+ ///+ /// A label appears exactly where the row draws a glyph: the table's+ /// `systemImage` is the one rule for "this value is marked", so the words+ /// and the mark cannot drift apart.+ private static func statusLabels(for work: WorkSnapshot) -> [String] {+ var labels: [String] = []+ if WorkStatusPresentation.systemImage(work.workStatus) != nil {+ labels.append(WorkStatusPresentation.accessibilityLabel(work.workStatus))+ }+ if ReadingStatusPresentation.systemImage(work.readingStatus) != nil {+ labels.append(ReadingStatusPresentation.accessibilityLabel(work.readingStatus))+ }+ return labels+ }++ /// The clauses as they trail a label, or "" where there are none.+ ///+ /// Joined with ". " rather than ", " (Q32): the label already lists several+ /// hostnames comma-separated, so a comma here would read as one more site.+ /// The separator also keeps the clauses behind the " from " the UI+ /// journeys' title parser reads up to.+ static func statusClause(for work: WorkSnapshot) -> String {+ let labels = statusLabels(for: work)+ guard !labels.isEmpty else { return "" }+ return ". " + labels.joined(separator: ". ")+ }++ /// The `work-title` element's own label (Req 5.5). An abandoned row states+ /// its state in words as well as in colour and opacity, which are the two+ /// channels Req 5.2 dims it through.+ static func titleLabel(for work: WorkSnapshot) -> String {+ work.displayTitle + statusClause(for: work) } /// One "Not the same work" pill: the Work it names, and what to call it.@@ -635,6 +699,11 @@ struct WorkRow: View { /// row is one line and Req 9.2's glyph is the site. var showsAllSites: Bool = false + /// `work-and-reading-status` Req 5.2: an abandoned work is one the reader+ /// put down, so its row steps back out of the active library rather than+ /// leaving it.+ private var isAbandoned: Bool { work.readingStatus == .abandoned }+ var body: some View { VStack(alignment: .leading, spacing: 4) { // Req 11.3: single-line work names truncate, never wrap.@@ -642,8 +711,12 @@ struct WorkRow: View { .font(AsterismTypography.serifRowTitle) .lineLimit(1) .truncationMode(.tail)- .foregroundStyle(AsterismColors.primaryText)+ .foregroundStyle(+ isAbandoned ? AsterismColors.secondaryText : AsterismColors.primaryText) .accessibilityIdentifier("work-title")+ // Req 5.5: the dimming is colour and opacity, neither of which+ // is a channel every reader has.+ .accessibilityLabel(WorksRowPresentation.titleLabel(for: work)) HStack(spacing: 6) { if showsAllSites {@@ -674,6 +747,22 @@ struct WorkRow: View { .accessibilityIdentifier("work-type-tag") } + // Req 5.1's marks, after the type tag and before the Spacer.+ // Drawn in the pill's own text font so a glyph is no taller+ // than the tag beside it, whatever the Dynamic Type size.+ if let symbol = WorkStatusPresentation.systemImage(work.workStatus) {+ statusGlyph(+ symbol, hue: WorkStatusPresentation.hue(work.workStatus),+ label: WorkStatusPresentation.accessibilityLabel(work.workStatus),+ identifier: "work-status-glyph")+ }+ if let symbol = ReadingStatusPresentation.systemImage(work.readingStatus) {+ statusGlyph(+ symbol, hue: ReadingStatusPresentation.hue(work.readingStatus),+ label: ReadingStatusPresentation.accessibilityLabel(work.readingStatus),+ identifier: "reading-status-glyph")+ }+ Spacer() // §7's count pill: cyan text on a cyan .12 fill.@@ -683,6 +772,22 @@ struct WorkRow: View { } } .frame(minHeight: AsterismLayout.minHitTarget)+ // Req 5.2: the whole row steps back, the way an ignored teach chip does+ // — the design language's one knock-down, not a second amount.+ .opacity(isAbandoned ? ConstellationRecipes.knockdownOpacity : 1)+ }++ /// One status mark. `.caption` semibold is `constellationPill`'s own text+ /// font, so the symbol's height sits inside the padded height of the type+ /// tag beside it (Req 5.1).+ private func statusGlyph(+ _ systemImage: String, hue: Color, label: String, identifier: String+ ) -> some View {+ Image(systemName: systemImage)+ .font(.caption.weight(.semibold))+ .foregroundStyle(hue)+ .accessibilityIdentifier(identifier)+ .accessibilityLabel(label) } }
diff --git a/Asterism/AsterismTests/DuplicateSurfaceTests.swift b/Asterism/AsterismTests/DuplicateSurfaceTests.swiftindex e61a30d..ad38ab4 100644--- a/Asterism/AsterismTests/DuplicateSurfaceTests.swift+++ b/Asterism/AsterismTests/DuplicateSurfaceTests.swift@@ -151,6 +151,59 @@ struct DuplicateSurfaceTests { #expect(model.workVariants.count == 2) } + // MARK: - The Work variant's status arms (V10 Req 7.2)++ /// Each arm is a line the sheet prints only where the set differs in that+ /// field, so both halves — the gate and the wording — are pinned here.+ ///+ /// The words name their dimension because both statuses have a value called+ /// "finished" (Q23), and a copy on `reading` still shows its verdict,+ /// because Q21 makes the sheet the one place the text Q7 hides is visible.+ @Test("The Work variant's three arms print only where the set differs, naming the dimension")+ func workVariantStatusArms() {+ let variant = Self.workVariant(+ workStatus: .hiatus, readingStatus: .reading, verdict: "put it down")++ #expect(+ DuplicateResolutionView.workStatusLine(variant, differing: [.workStatus])+ == "Work: On hiatus")+ #expect(+ DuplicateResolutionView.readingStatusLine(variant, differing: [.readingStatus])+ == "Reading: Reading")+ #expect(+ DuplicateResolutionView.verdictLine(variant, differing: [.verdict])+ == "Verdict: put it down")++ // A field the set agrees on is not a decision, so its arm prints+ // nothing — the sheet's standing rule for every other field.+ #expect(DuplicateResolutionView.workStatusLine(variant, differing: [.verdict]) == nil)+ #expect(DuplicateResolutionView.readingStatusLine(variant, differing: [.verdict]) == nil)+ #expect(DuplicateResolutionView.verdictLine(variant, differing: [.workStatus]) == nil)+ // An empty verdict has nothing to show even where the set differs in it,+ // and neither has a blank one — the fold's own test for a verdict worth+ // carrying is `M2Unicode.isBlank`, so the sheet's has to be too.+ #expect(+ DuplicateResolutionView.verdictLine(+ Self.workVariant(verdict: ""), differing: [.verdict]) == nil)+ #expect(+ DuplicateResolutionView.verdictLine(+ Self.workVariant(verdict: " \u{2028}\t"), differing: [.verdict]) == nil)+ }++ @Test("Every status value has a word in the sheet's arms")+ func workVariantStatusArmsNameEveryValue() {+ #expect(+ WorkStatus.allCases.map {+ DuplicateResolutionView.workStatusLine(+ Self.workVariant(workStatus: $0), differing: [.workStatus])+ } == ["Work: Ongoing", "Work: Finished", "Work: On hiatus"])+ #expect(+ ReadingStatus.allCases.map {+ DuplicateResolutionView.readingStatusLine(+ Self.workVariant(readingStatus: $0), differing: [.readingStatus])+ } == ["Reading: Reading", "Reading: Finished", "Reading: Abandoned"])+ }+ // MARK: - EntryDetailModel (Reqs 2.8, 2.10) @Test("A torn record is read-only and its unavailability names the review")@@ -559,6 +612,7 @@ struct DuplicateSurfaceTests { id: VariantID(rawValue: "work-variant-\(index)"), displayTitle: "Serial", manualTitle: nil, genericNotes: "notes \(index)", workURLString: nil, genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "", firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000 + Double(index))) } return .work(@@ -566,6 +620,19 @@ struct DuplicateSurfaceTests { variants: variants, differingFields: [.genericNotes], preselected: variants[0].id) } + private static func workVariant(+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = ""+ ) -> WorkVariantChoice {+ WorkVariantChoice(+ id: VariantID(rawValue: "work-variant"),+ displayTitle: "Serial", manualTitle: nil, genericNotes: "",+ workURLString: nil, genreTags: [], typeDisplay: .untyped,+ workStatus: workStatus, readingStatus: readingStatus, verdict: verdict,+ firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000))+ }+ private static func teachingDetail( _ entry: EntrySnapshot, _ groupState: RecordGroupState<EntryAuthoredContent> ) -> EntryTeachingDetail {
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex c7ef790..0bb21cf 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -58,6 +58,11 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { var lastMoveDestination: WorkDestination? var lastUpdateEntryBasis: EntryEditBasis? var lastUpdateWorkBasis: WorkEditBasis?+ /// What the editor actually sent. Recorded beside the basis because+ /// `updateWork` writes every field of the draft to every row of the group+ /// (Req 7.1 of `work-and-reading-status`): a draft that quietly carried a+ /// default status would reset a reader's value with nothing to see.+ var lastUpdateWorkDraft: WorkMetadataDraft? var lastMoveEntryBasis: EntryAssignmentBasis? enum MockError: Error, LocalizedError {@@ -142,6 +147,7 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable { ) async throws -> LibraryWriteOutcome { updateWorkCallCount += 1 lastUpdateWorkBasis = basis+ lastUpdateWorkDraft = draft return try updateWorkResult.get() }
diff --git a/Asterism/AsterismTests/Helpers/TestFixtures.swift b/Asterism/AsterismTests/Helpers/TestFixtures.swiftindex a1d0f33..061faa7 100644--- a/Asterism/AsterismTests/Helpers/TestFixtures.swift+++ b/Asterism/AsterismTests/Helpers/TestFixtures.swift@@ -174,7 +174,13 @@ enum TestFixtures { entries: [EntrySnapshot] = [], createdAt: Date = fixedDate, modifiedAt: Date = fixedDate,- groupState: RecordGroupState<WorkAuthoredContent> = .single+ groupState: RecordGroupState<WorkAuthoredContent> = .single,+ /// Defaulted like the snapshot's own three (Q40): a fixture that says+ /// nothing about status describes a work on the defaults, so every case+ /// written before V10 keeps meaning what it meant.+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) -> WorkSnapshot { WorkSnapshot( id: id,@@ -189,7 +195,10 @@ enum TestFixtures { createdAt: createdAt, modifiedAt: modifiedAt, entries: entries,- groupState: groupState+ groupState: groupState,+ workStatus: workStatus,+ readingStatus: readingStatus,+ verdict: verdict ) } }
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex a831bd3..845f184 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -144,15 +144,16 @@ struct IntegrationSafetyNetTests { typeAssignment: .configured( UUID(uuidString: "0E7A0000-0000-4000-8000-0000000000A1")!), genreTags: ["science fiction", "serial"],- genericNotes: "Work-level notes"+ genericNotes: "Work-level notes",+ workStatus: .ongoing, readingStatus: .reading, verdict: "" ) ) let stagingDirectory = fixture.baseDirectory.appending(path: "validated-backups")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: stagingDirectory)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: stagingDirectory) let exportedAt = Date(timeIntervalSince1970: 1_784_246_400) let result = try await exporter.export(- metadata: BackupV8Metadata(+ metadata: BackupV9Metadata( appBuild: "integration-1", exportedAt: exportedAt )@@ -160,8 +161,8 @@ struct IntegrationSafetyNetTests { defer { exporter.cleanup(result) } let encoded = try Data(contentsOf: result.fileURL)- let decoded = try BackupV8Codec.decode(encoded)- let source = try await repository.backupV8Snapshot()+ let decoded = try BackupV9Codec.decode(encoded)+ let source = try await repository.backupV9Snapshot() #expect(decoded.payload == source) #expect(decoded.payload.entries.count == 1)@@ -234,12 +235,13 @@ struct IntegrationSafetyNetTests { Issue.record("Expected Backup export for \(environment) \(capabilities.gate.rawValue)") continue }- // Settings writes 8/9 now (Req 5.1): the archive has to carry a- // Work's site memberships, the reader's dismissed pairs and- // version-free citations, so the round-trip is reachable from- // the surface the reader uses. The gate the file declares is- // still the running one.- let document = try BackupV8Codec.decode(Data(contentsOf: backupURL))+ // Settings writes 9/10 now (Req 8.1): the archive has to carry+ // a Work's site memberships, the reader's dismissed pairs,+ // version-free citations and the Work's two statuses and+ // verdict, so the round-trip is reachable from the surface the+ // reader uses. The gate the file declares is still the running+ // one.+ let document = try BackupV9Codec.decode(Data(contentsOf: backupURL)) #expect(document.capabilityGate == AsterismCapabilities.current.gate.rawValue) backup.handleShareCancellation() }@@ -324,9 +326,9 @@ struct IntegrationSafetyNetTests { try await sourceRepo.moveEntry(entry.id, to: .existing(work.id)) let stagingDir = fixture.baseDirectory.appending(path: "export-stage")- let exporter = BackupV8Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+ let exporter = BackupV9Exporter(repository: sourceRepo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "fill-test", exportedAt: Date())+ metadata: BackupV9Metadata(appBuild: "fill-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let backupData = try Data(contentsOf: exportResult.fileURL)@@ -389,9 +391,9 @@ struct IntegrationSafetyNetTests { ) ) let stagingDir = fixture.baseDirectory.appending(path: "restore-stage")- let exporter = BackupV8Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+ let exporter = BackupV9Exporter(repository: sourceRepo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "restore-test", exportedAt: Date())+ metadata: BackupV9Metadata(appBuild: "restore-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let plan = try BackupImporter.plan(from: try Data(contentsOf: exportResult.fileURL))@@ -684,7 +686,8 @@ struct IntegrationSafetyNetTests { displayTitle: "Source Work Manual", typeAssignment: .none, genreTags: ["tag-a"],- genericNotes: "Source notes to audit"+ genericNotes: "Source notes to audit",+ workStatus: .ongoing, readingStatus: .reading, verdict: "" ) ) @@ -896,15 +899,15 @@ struct IntegrationSafetyNetTests { ) let stagingDir = fixture.baseDirectory.appending(path: "corrupt-stage")- let exporter = BackupV8Exporter(repository: repo, stagingDirectory: stagingDir)+ let exporter = BackupV9Exporter(repository: repo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "corrupt-test", exportedAt: Date())+ metadata: BackupV9Metadata(appBuild: "corrupt-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } // Verify good backup decodes let goodData = try Data(contentsOf: exportResult.fileURL)- let decoded = try BackupV8Codec.decode(goodData)+ let decoded = try BackupV9Codec.decode(goodData) #expect(decoded.payload.entries.count == 1) // Corrupt the data by flipping bytes in the payload area@@ -917,7 +920,7 @@ struct IntegrationSafetyNetTests { // Corrupted backup should fail decode/checksum do {- _ = try BackupV8Codec.decode(corruptData)+ _ = try BackupV9Codec.decode(corruptData) Issue.record("Expected corrupted backup to fail validation") } catch { // Expected: checksum or decode failure@@ -1004,13 +1007,13 @@ struct IntegrationSafetyNetTests { // Export the archive let stagingDir = fixture.baseDirectory.appending(path: "url-backup-stage")- let exporter = BackupV8Exporter(repository: repo, stagingDirectory: stagingDir)+ let exporter = BackupV9Exporter(repository: repo, stagingDirectory: stagingDir) let exportResult = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "url-backup-test", exportedAt: Date())+ metadata: BackupV9Metadata(appBuild: "url-backup-test", exportedAt: Date()) ) defer { exporter.cleanup(exportResult) } let backupData = try Data(contentsOf: exportResult.fileURL)- let decoded = try BackupV8Codec.decode(backupData)+ let decoded = try BackupV9Codec.decode(backupData) // Site should be present in the payload. let site = decoded.payload.sites.first { $0.hostname == "backupurl.test" }
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex 578a0f4..4be4da1 100644--- a/Asterism/AsterismTests/SettingsBackupModelTests.swift+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -213,7 +213,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessageStatesTheCount() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV8ExportError.tornGroups(+ BackupV9ExportError.tornGroups( TornGroupsPayload(count: 3, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -234,7 +234,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessageReadsSingular() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV8ExportError.tornGroups(+ BackupV9ExportError.tornGroups( TornGroupsPayload(count: 1, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -252,7 +252,7 @@ struct SettingsBackupModelTests { @MainActor func tornGroupsMessagePointsAtTheBlockingWorkSet() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV8ExportError.tornGroups(+ BackupV9ExportError.tornGroups( TornGroupsPayload( count: 1, blockingWorkSet: DuplicateSetKey(@@ -288,7 +288,7 @@ struct SettingsBackupModelTests { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV8ExportError.tornGroups(+ BackupV9ExportError.tornGroups( TornGroupsPayload(count: 1, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock) await model.startExport()@@ -302,19 +302,19 @@ struct SettingsBackupModelTests { #expect(!model.routesToCheckLibrary) } - // MARK: - Archive generation 8/9 (rule-citation-by-uuid Req 5.1)+ // MARK: - Archive generation 9/10 (work-and-reading-status Req 8.1) /// The Settings surface is the only place the app *writes* an archive, so a- /// repository that reaches 8/9 while this seam still asks for 7/8 leaves the+ /// repository that reaches 9/10 while this seam still asks for 8/9 leaves the /// round-trip `multi-site-works` Req 9.2 promises unreachable. The metadata /// type is the tell: the exporter this model holds is the one whose payload- /// carries version-free citations.- @Test("The export surface asks the 8/9 exporter for the archive")- @MainActor func exportsArchiveGenerationEightNine() async {+ /// carries a Work's statuses and verdict.+ @Test("The export surface asks the 9/10 exporter for the archive")+ @MainActor func exportsArchiveGenerationNineTen() async { let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) defer { try? FileManager.default.removeItem(at: tempDir) }- let fakeURL = tempDir.appending(path: "Asterism-backup-89.json")+ let fakeURL = tempDir.appending(path: "Asterism-backup-910.json") try? Data("{}".utf8).write(to: fakeURL) let mock = MockBackupExporting()@@ -323,7 +323,7 @@ struct SettingsBackupModelTests { let model = SettingsBackupModel(exporter: mock) await model.startExport() - let metadata: BackupV8Metadata? = mock.lastMetadata+ let metadata: BackupV9Metadata? = mock.lastMetadata #expect(metadata != nil) #expect(metadata?.appBuild.isEmpty == false) }@@ -331,14 +331,14 @@ struct SettingsBackupModelTests { /// Req 6.5, through Q105: a torn **character** group refuses the export the /// same way a torn Work or Entry does, and the reader is sent to the same /// place. The payload carries a count, not a record kind, so what this pins- /// is that the 8/9 refusal reaches a message arm at all — an unhandled case+ /// is that the 9/10 refusal reaches a message arm at all — an unhandled case /// would fall through to the generic "please try again", which is the dead /// end Decision 20 already removed once.- @Test("An 8/9 torn refusal routes the reader to Check Library")- @MainActor func eightNineTornRefusalRoutes() async {+ @Test("A 9/10 torn refusal routes the reader to Check Library")+ @MainActor func nineTenTornRefusalRoutes() async { let mock = MockBackupExporting() mock.exportResult = .failure(- BackupV8ExportError.tornGroups(+ BackupV9ExportError.tornGroups( TornGroupsPayload(count: 2, blockingWorkSet: nil))) let model = SettingsBackupModel(exporter: mock)@@ -351,18 +351,18 @@ struct SettingsBackupModelTests { #expect(model.routesToCheckLibrary) } - /// Every case of the 8/9 refusal has a message of its own. A case that fell+ /// Every case of the 9/10 refusal has a message of its own. A case that fell /// through to the default arm would be indistinguishable from an error the /// app has never heard of.- @Test("Every 8/9 export refusal has its own message", arguments: [- BackupV8ExportError.referencesStillArriving(detail: "rule 1"),- BackupV8ExportError.unrepresentableValue(+ @Test("Every 9/10 export refusal has its own message", arguments: [+ BackupV9ExportError.referencesStillArriving(detail: "rule 1"),+ BackupV9ExportError.unrepresentableValue( record: "Character", field: "factsData", value: "…"),- BackupV8ExportError.snapshotFailed(reason: "read"),- BackupV8ExportError.encodingFailed(reason: "encode"),- BackupV8ExportError.stagingFailed(reason: "stage"),+ BackupV9ExportError.snapshotFailed(reason: "read"),+ BackupV9ExportError.encodingFailed(reason: "encode"),+ BackupV9ExportError.stagingFailed(reason: "stage"), ])- @MainActor func everyEightNineRefusalHasAMessage(error: BackupV8ExportError) async {+ @MainActor func everyNineTenRefusalHasAMessage(error: BackupV9ExportError) async { let mock = MockBackupExporting() mock.exportResult = .failure(error) @@ -397,12 +397,12 @@ final class MockBackupExporting: BackupExporting, @unchecked Sendable { var cleanupCallCount = 0 var scavengeCallCount = 0 var lastCleanupURL: URL?- var lastMetadata: BackupV8Metadata?+ var lastMetadata: BackupV9Metadata? var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured) var exportDelay: Duration? - func export(metadata: BackupV8Metadata) async throws -> BackupExportResult {+ func export(metadata: BackupV9Metadata) async throws -> BackupExportResult { exportCallCount += 1 lastMetadata = metadata if let delay = exportDelay {
diff --git a/Asterism/AsterismTests/SettingsImportTests.swift b/Asterism/AsterismTests/SettingsImportTests.swiftindex 729e579..f2f3095 100644--- a/Asterism/AsterismTests/SettingsImportTests.swift+++ b/Asterism/AsterismTests/SettingsImportTests.swift@@ -62,14 +62,14 @@ enum MockSetupError: Error, LocalizedError { @Suite("SettingsBackupImportModel") struct SettingsBackupImportModelTests { - /// A real 8/9 document, because the model plans the bytes it is handed —+ /// A real 9/10 document, because the model plans the bytes it is handed — /// stubbing the planner would leave the preview untested. static let minimalBackupData: Data = { let hostname = "settings-import.example" let rawURL = "https://\(hostname)/read?chapter=1"- let payload = BackupV8Payload(+ let payload = BackupV9Payload( entries: [- BackupV8Entry(+ BackupV9Entry( id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: hostname, entryIdentityKey: rawURL,@@ -84,14 +84,14 @@ struct SettingsBackupImportModelTests { ], works: [], sites: [- BackupV8Site(+ BackupV9Site( hostname: hostname, displayName: hostname, mode: .untaught, junkSuffixRule: nil) ], titlePatterns: [], urlRules: [], workTypes: [])- return try! BackupV8Codec.encode(+ return try! BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(+ metadata: BackupV9Metadata( appBuild: "test", exportedAt: Date(timeIntervalSince1970: 1_800_000_000))) }()
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex 67eb628..5c9b0cc 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -894,6 +894,407 @@ struct WorkDetailModelTests { #expect(model.chapterRows(for: .newest).map(\.id) == rows.map(\.id)) #expect(model.chapterRows(for: .newest).map(\.id) == model.chapterRows.map(\.id)) }++ // MARK: - The two statuses, the verdict, and the finished-reading rule+ // (`work-and-reading-status` Reqs 1.2, 2.2–2.5, 3.1–3.5, 7.1)++ /// Req 3.1 and Decision 1: `finished` reading means the reader read the whole+ /// work, so choosing it on a work that is not itself finished asks rather+ /// than writes. The capsule keeps showing the previous value while the+ /// dialog is up — the draft is deliberately not moved.+ @MainActor @Test(+ "Choosing finished reading on an unfinished work raises the prompt and moves nothing",+ arguments: [WorkStatus.ongoing, .hiatus], [ReadingStatus.reading, .abandoned])+ func finishedReadingOnAnUnfinishedWorkPrompts(+ work workStatus: WorkStatus, reading readingStatus: ReadingStatus+ ) async {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: workStatus, readingStatus: readingStatus))+ await model.load()+ model.beginEditing()++ model.setDraftReadingStatus(.finished)++ #expect(model.finishedReadingPrompt?.thenCommits == false)+ #expect(model.draftReadingStatus == readingStatus)+ #expect(model.draftWorkStatus == workStatus)+ #expect(mock.updateWorkCallCount == 0)+ }++ /// Req 3.5: a stored pair that violates the rule is shown as stored. Tapping+ /// the segment it is already on is not a change, so it must not ask.+ @MainActor @Test("Re-selecting the reading status a stored violating pair already has is a no-op")+ func reSelectingTheStoredFinishedReadingDoesNotPrompt() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()++ model.setDraftReadingStatus(.finished)++ #expect(model.finishedReadingPrompt == nil)+ #expect(model.draftReadingStatus == .finished)+ }++ /// Req 3.3 and Q7: a work that stops being finished cannot leave a finished+ /// reading behind it, and the reader is not punished for the mis-tap with a+ /// dialog. Req 2.5: the verdict text survives the revert — it is only hidden.+ @MainActor @Test("Leaving a finished work reverts finished reading and keeps the verdict")+ func leavingAFinishedWorkRevertsTheReading() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(+ workStatus: .finished, readingStatus: .finished, verdict: "worth the wait"))+ await model.load()+ model.beginEditing()++ model.setDraftWorkStatus(.hiatus)++ #expect(model.draftReadingStatus == .reading)+ #expect(model.draftVerdict == "worth the wait")+ #expect(model.finishedReadingPrompt == nil)+ #expect(model.autoRevertedReading)+ }++ /// Q39: the mis-tap Q7 protects is undone in full. Putting the work back on+ /// `finished` in the same session restores the reading status the revert+ /// took, rather than costing the reader a second selection.+ @MainActor @Test("A work put back on finished restores the reading status the revert took")+ func returningToFinishedRestoresTheAutoRevertedReading() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .finished, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.ongoing)+ #expect(model.draftReadingStatus == .reading)++ model.setDraftWorkStatus(.finished)++ #expect(model.draftReadingStatus == .finished)+ #expect(!model.autoRevertedReading)+ #expect(model.finishedReadingPrompt == nil)+ }++ /// Q39's other half: any other reading change clears the flag, so the+ /// restore never overwrites a value the reader chose after the revert.+ @MainActor @Test("A reading status chosen after the revert is not overwritten by the restore")+ func aChosenReadingStatusSurvivesTheReturnToFinished() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .finished, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.ongoing)+ model.setDraftReadingStatus(.abandoned)++ model.setDraftWorkStatus(.finished)++ #expect(model.draftReadingStatus == .abandoned)+ }++ /// Req 3.5 and Q20: the commit-time check is gated on the draft pair+ /// differing from the stored one, so committing a stored violating pair+ /// unchanged writes it back rather than interrogating the reader about a+ /// state they did not create.+ @MainActor @Test("Committing a stored violating pair unchanged writes it back without a prompt")+ func committingAStoredViolatingPairWritesItBack() async {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.draftNotes = "a note, and nothing about either status"++ await model.commitEditing()++ #expect(model.finishedReadingPrompt == nil)+ #expect(mock.updateWorkCallCount == 1)+ #expect(mock.lastUpdateWorkDraft?.workStatus == .ongoing)+ #expect(mock.lastUpdateWorkDraft?.readingStatus == .finished)+ #expect(!model.isEditing)+ }++ /// Req 3.4: the same pair, but the reader moved the *work* side — Q20's case,+ /// which no picker transition catches. The check runs first in+ /// `commitEditing()`, so nothing is written and no other step has run.+ @MainActor @Test("Editing the work side of a violating pair raises the prompt at commit")+ func commitPromptsWhenTheDraftPairDiffers() async {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)++ await model.commitEditing()++ #expect(model.finishedReadingPrompt?.thenCommits == true)+ #expect(mock.updateWorkCallCount == 0)+ #expect(model.isEditing)+ }++ /// Req 3.4's first action: apply the change, then commit.+ @MainActor @Test("Marking the work finished at the commit prompt applies both and saves")+ func commitPromptMarkWorkFinishedFollowsThrough() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)+ await model.commitEditing()+ let prompt = try #require(model.finishedReadingPrompt)++ await model.resolveFinishedReadingPrompt(prompt, choosing: .markWorkFinished)++ #expect(model.finishedReadingPrompt == nil)+ #expect(mock.updateWorkCallCount == 1)+ // What was *sent*, not what the drafts hold afterwards: a committed save+ // reloads the screen, and the drafts are then the store's again.+ #expect(mock.lastUpdateWorkDraft?.workStatus == .finished)+ #expect(mock.lastUpdateWorkDraft?.readingStatus == .finished)+ #expect(!model.isEditing)+ }++ /// Req 3.4's second action: `abandoned` leaves the work status alone.+ @MainActor @Test("Abandoned instead at the commit prompt records abandoned and saves")+ func commitPromptAbandonInsteadFollowsThrough() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)+ await model.commitEditing()+ let prompt = try #require(model.finishedReadingPrompt)++ await model.resolveFinishedReadingPrompt(prompt, choosing: .abandonInstead)++ #expect(mock.updateWorkCallCount == 1)+ // The work status is left alone; only the reading side moves.+ #expect(mock.lastUpdateWorkDraft?.workStatus == .hiatus)+ #expect(mock.lastUpdateWorkDraft?.readingStatus == .abandoned)+ #expect(!model.isEditing)+ }++ /// Req 3.4's third: cancel returns to edit mode with nothing written. The+ /// drafts were never moved by the prompt, so there is nothing to restore —+ /// which is why the check runs ahead of the URL step.+ ///+ /// Driven through `cancelFinishedReadingPrompt()` because that is the only+ /// way the dialog cancels: both its Cancel button and its dismissal call it,+ /// and the resolution enum has no `.cancel` case to reach.+ @MainActor @Test("Cancelling the prompt leaves the drafts as they were and writes nothing")+ func cancellingThePromptWritesNothing() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)+ model.draftVerdict = "not yet decided"+ await model.commitEditing()+ _ = try #require(model.finishedReadingPrompt)++ model.cancelFinishedReadingPrompt()++ #expect(model.finishedReadingPrompt == nil)+ #expect(model.draftWorkStatus == .hiatus)+ #expect(model.draftReadingStatus == .finished)+ #expect(model.draftVerdict == "not yet decided")+ #expect(mock.updateWorkCallCount == 0)+ #expect(model.isEditing)+ }++ /// Req 3.2's first action on the *picker* arm: the same two answers, but the+ /// prompt was raised by the capsule rather than the checkmark, so the drafts+ /// move and the reader stays in the editor with nothing saved.+ @MainActor @Test("Marking the work finished at the picker prompt moves both drafts without saving")+ func pickerPromptMarkWorkFinishedMovesTheDrafts() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .reading))+ await model.load()+ model.beginEditing()+ model.draftVerdict = "a keeper"+ model.setDraftReadingStatus(.finished)+ let prompt = try #require(model.finishedReadingPrompt)+ #expect(prompt.thenCommits == false)++ await model.resolveFinishedReadingPrompt(prompt, choosing: .markWorkFinished)++ #expect(model.finishedReadingPrompt == nil)+ #expect(model.draftWorkStatus == .finished)+ #expect(model.draftReadingStatus == .finished)+ // The verdict the reader had already typed is theirs to keep.+ #expect(model.draftVerdict == "a keeper")+ #expect(mock.updateWorkCallCount == 0)+ #expect(model.isEditing)+ }++ /// Req 3.2's second: `abandoned` leaves the work status where it was, and+ /// still writes nothing — the checkmark is the reader's next step.+ @MainActor @Test("Abandoned instead at the picker prompt records abandoned without saving")+ func pickerPromptAbandonInsteadMovesOnlyTheReading() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .reading))+ await model.load()+ model.beginEditing()+ model.setDraftReadingStatus(.finished)+ let prompt = try #require(model.finishedReadingPrompt)++ await model.resolveFinishedReadingPrompt(prompt, choosing: .abandonInstead)++ #expect(model.finishedReadingPrompt == nil)+ #expect(model.draftReadingStatus == .abandoned)+ #expect(model.draftWorkStatus == .ongoing)+ #expect(mock.updateWorkCallCount == 0)+ #expect(model.isEditing)+ }++ /// The follow-through is an ordinary commit, so it meets the ordinary URL+ /// refusal: the editor stays open with the reader's choice visible in the+ /// capsules rather than silently dropped, and nothing is written.+ @MainActor @Test("A URL refused on the follow-through keeps the resolved statuses in the drafts")+ func aURLRefusalOnTheFollowThroughKeepsTheResolvedDrafts() async throws {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .ongoing, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)+ model.draftWorkURL = "ftp://example.com/work"+ await model.commitEditing()+ let prompt = try #require(model.finishedReadingPrompt)++ await model.resolveFinishedReadingPrompt(prompt, choosing: .markWorkFinished)++ #expect(model.draftWorkStatus == .finished)+ #expect(model.draftReadingStatus == .finished)+ #expect(model.workURLStatusMessage != nil)+ #expect(mock.updateWorkCallCount == 0)+ #expect(model.isEditing)+ }++ /// Req 1.2 and 2.2: a status is a metadata change like any other, so the+ /// checkmark appears for it.+ @MainActor @Test("A status-only change is an unsaved change")+ func aStatusOnlyChangeIsDirty() async {+ let (model, _, _) = makeSUT()+ await model.load()+ model.beginEditing()+ #expect(!model.hasUnsavedChanges)++ model.setDraftWorkStatus(.hiatus)++ #expect(model.hasUnsavedChanges)+ }++ /// Req 2.3: so is the verdict on its own.+ @MainActor @Test("A verdict-only change is an unsaved change")+ func aVerdictOnlyChangeIsDirty() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(readingStatus: .abandoned))+ await model.load()+ model.beginEditing()+ #expect(!model.hasUnsavedChanges)++ model.draftVerdict = "lost the thread around chapter 40"++ #expect(model.hasUnsavedChanges)+ }++ /// Req 2.5: a verdict typed and then hidden by a status change is still a+ /// change, and still the reader's text. Dropping it because the field went+ /// away would lose typing to a tap on an unrelated control.+ @MainActor @Test("A verdict typed and then hidden by a status change is kept and still dirty")+ func aHiddenVerdictIsKeptAndStillDirty() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(workStatus: .finished, readingStatus: .finished))+ await model.load()+ model.beginEditing()+ model.draftVerdict = "a great ending"++ model.setDraftWorkStatus(.ongoing)++ #expect(model.draftReadingStatus == .reading)+ #expect(model.draftVerdict == "a great ending")+ #expect(model.hasUnsavedChanges)+ }++ /// The X is a discard: all three go back to what the screen loaded.+ @MainActor @Test("Cancelling the editor restores both statuses and the verdict")+ func cancellingTheEditorRestoresTheStatuses() async {+ let (model, _, _) = makeSUT(+ work: TestFixtures.makeWork(+ workStatus: .hiatus, readingStatus: .abandoned, verdict: "stalled"))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.ongoing)+ model.setDraftReadingStatus(.reading)+ model.draftVerdict = "typed and abandoned"++ model.cancelEditing()++ #expect(model.draftWorkStatus == .hiatus)+ #expect(model.draftReadingStatus == .abandoned)+ #expect(model.draftVerdict == "stalled")+ #expect(!model.hasUnsavedChanges)+ }++ /// Req 7.1 and Q40: the draft carries what the capsules show, not what the+ /// screen loaded — a draft holding the loaded values would write a reader's+ /// change away. The basis carries the loaded values, which is what makes a+ /// post-collapse redirect refuse (Req 7.4).+ @MainActor @Test("Save sends the drafted statuses and verdict, over the loaded basis")+ func saveSendsTheDraftedStatuses() async {+ let (model, mock, _) = makeSUT(+ work: TestFixtures.makeWork(+ workStatus: .finished, readingStatus: .reading, verdict: ""))+ await model.load()+ model.beginEditing()+ model.setDraftWorkStatus(.hiatus)+ model.setDraftReadingStatus(.abandoned)+ model.draftVerdict = "gave up at the timeskip"++ await model.commitEditing()++ #expect(mock.lastUpdateWorkDraft?.workStatus == .hiatus)+ #expect(mock.lastUpdateWorkDraft?.readingStatus == .abandoned)+ #expect(mock.lastUpdateWorkDraft?.verdict == "gave up at the timeskip")+ #expect(mock.lastUpdateWorkBasis?.workStatus == .finished)+ #expect(mock.lastUpdateWorkBasis?.readingStatus == .reading)+ #expect(mock.lastUpdateWorkBasis?.verdict == "")+ }++ /// Q47: with no snapshot to build a basis from, the fallback states all+ /// three from the drafts. A fallback quietly taking the parameter defaults+ /// would send `ongoing`/`reading`/`""` and refuse every write on a marked+ /// work.+ @MainActor @Test("The nil-snapshot fallback basis states the drafted statuses")+ func theFallbackBasisStatesTheStatuses() async {+ let (model, mock, _) = makeSUT()+ mock.workDetailResult = .failure(+ MockLibraryProvider.MockError.simulatedFailure("no detail"))+ await model.load()+ #expect(model.work == nil)+ model.setDraftWorkStatus(.finished)+ model.setDraftReadingStatus(.finished)+ model.draftVerdict = "read it twice"++ await model.save()++ #expect(mock.lastUpdateWorkBasis?.workStatus == .finished)+ #expect(mock.lastUpdateWorkBasis?.readingStatus == .finished)+ #expect(mock.lastUpdateWorkBasis?.verdict == "read it twice")+ #expect(mock.lastUpdateWorkDraft?.workStatus == .finished)+ #expect(mock.lastUpdateWorkDraft?.readingStatus == .finished)+ #expect(mock.lastUpdateWorkDraft?.verdict == "read it twice")+ }++ /// The two prompts say different things: one is a choice about a control,+ /// the other is a choice about a save that has not happened.+ @MainActor @Test("The prompt's message names the rule and says whether anything is saved")+ func thePromptMessageReadsForBothArms() {+ let atThePicker = FinishedReadingPrompt(id: UUID(), thenCommits: false)+ let atTheCommit = FinishedReadingPrompt(id: UUID(), thenCommits: true)++ #expect(atThePicker.message.contains("not marked finished"))+ #expect(atTheCommit.message.contains("not marked finished"))+ #expect(atTheCommit.message.contains("Nothing has been saved"))+ #expect(atThePicker.message != atTheCommit.message)+ } } /// The work detail screen on a Work that is on more than one site
diff --git a/Asterism/AsterismTests/WorkMergeModelTests.swift b/Asterism/AsterismTests/WorkMergeModelTests.swiftindex e4e5301..9d04c5f 100644--- a/Asterism/AsterismTests/WorkMergeModelTests.swift+++ b/Asterism/AsterismTests/WorkMergeModelTests.swift@@ -81,6 +81,64 @@ struct WorkMergeModelTests { #expect(model.outcome?.discardedFields.contains(.sourceWorkURL) == true) } + // MARK: - What the discarded list says (V10 Req 7.3)++ /// The preview used to caption every discarded field "Recorded in merged+ /// notes", which was only ever true of the title, the URL and the notes.+ /// With two statuses that are dropped outright it became a false statement,+ /// so the caption reads the field's own answer (Q13).+ @Test("A discarded field is captioned by whether the merged notes record it")+ func discardedCaptionsFollowTheField() {+ #expect(+ WorkMergeView.discardedCaption(.sourceVerdict) == "Recorded in merged notes")+ #expect(WorkMergeView.discardedCaption(.sourceNotes) == "Recorded in merged notes")+ #expect(WorkMergeView.discardedCaption(.sourceManualTitle) == "Recorded in merged notes")+ #expect(WorkMergeView.discardedCaption(.sourceWorkURL) == "Recorded in merged notes")+ #expect(WorkMergeView.discardedCaption(.sourceWorkStatus) == "Not carried over")+ #expect(WorkMergeView.discardedCaption(.sourceReadingStatus) == "Not carried over")+ // `.sourceGenreTags` is not pinned here: the fold only ever *retains*+ // it (`WorkVariantUnion.fold`), so no caption of it is ever shown and+ // "not carried over" would be the wrong words for tags that are.+ }++ /// Req 7.3's other two sentences. The disclosure label and the discarded+ /// row's spoken line are the only places the preview *promises* what the+ /// merged notes will hold, so the words are pinned rather than left to a+ /// screen nobody reads back.+ @Test("The audit disclosure and the discarded row say what Req 7.3 promises")+ func auditAndDiscardedRowWording() {+ #expect(+ WorkMergeView.auditBlockLabel+ == "Audit block: the discarded values marked recorded in merged notes are added to the target's notes"+ )+ #expect(+ WorkMergeView.discardedRowLabel(.sourceVerdict)+ == "Discarded: Source verdict, recorded in merged notes")+ #expect(+ WorkMergeView.discardedRowLabel(.sourceNotes)+ == "Discarded: Source notes, recorded in merged notes")+ // The half the label exists to keep honest: a dropped status is listed+ // beside the recorded ones and says so.+ #expect(+ WorkMergeView.discardedRowLabel(.sourceWorkStatus)+ == "Discarded: Source work status, not carried over")+ #expect(+ WorkMergeView.discardedRowLabel(.sourceReadingStatus)+ == "Discarded: Source reading status, not carried over")+ }++ @Test("The six status and verdict fields have labels of their own")+ func statusFieldLabels() {+ #expect(WorkMergeView.fieldLabel(.targetWorkStatus) == "Target work status")+ #expect(WorkMergeView.fieldLabel(.targetReadingStatus) == "Target reading status")+ #expect(WorkMergeView.fieldLabel(.targetVerdict) == "Target verdict")+ #expect(WorkMergeView.fieldLabel(.sourceWorkStatus) == "Source work status")+ #expect(WorkMergeView.fieldLabel(.sourceReadingStatus) == "Source reading status")+ #expect(WorkMergeView.fieldLabel(.sourceVerdict) == "Source verdict")+ // No field goes unlabelled: the preview lists whatever the fold names.+ #expect(WorkMergeField.allCases.allSatisfy { !WorkMergeView.fieldLabel($0).isEmpty })+ }+ // MARK: - Audit disclosure @Test("Outcome exposes exact audit block for preview before confirmation")@@ -479,6 +537,41 @@ struct MergeDestinationPickerTests { #expect(refused.contains("Needs attention.")) } + /// `work-and-reading-status` Req 5.4, Q22: the picker draws the same glyphs+ /// the library row does, and its rows are buttons too — so the same trailing+ /// clauses have to reach the reader who cannot see them. A destination that+ /// was abandoned is worth knowing before merging into it.+ @Test("A candidate's label trails the status clauses the row's glyphs draw")+ @MainActor func theCandidateLabelTrailsTheStatusClauses() {+ let work = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example",+ entries: [TestFixtures.makeEntry(hostname: "a.example")],+ workStatus: .hiatus, readingStatus: .abandoned)++ #expect(+ WorkMergeView.destinationLabel(work, unavailable: nil)+ == "Merge into Zephyr, on a.example, 1 note. Work: On hiatus. Reading: Abandoned")++ // The refusal is a capitalised sentence of its own, so it follows the+ // last clause behind a full stop rather than a comma splice.+ #expect(+ WorkMergeView.destinationLabel(+ work,+ unavailable:+ "a.example needs attention in Check Library before this Work can be merged into."+ )+ == "Merge into Zephyr, on a.example, 1 note. Work: On hiatus. Reading: Abandoned. "+ + "a.example needs attention in Check Library before this Work can be merged into.")++ // A work on both defaults reads exactly as it did before V10.+ let plain = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example",+ entries: [TestFixtures.makeEntry(hostname: "a.example")])+ #expect(+ WorkMergeView.destinationLabel(plain, unavailable: nil)+ == "Merge into Zephyr, on a.example, 1 note")+ }+ /// Req 4.1 asks the picker for the membership **hostnames**, not only their /// colours. The row is `WorkRow(showsAllSites: true)`, which draws a /// `SiteLabel` — glyph *and* hostname text — per membership, each addressed
diff --git a/Asterism/AsterismTests/WorkStatusPresentationTests.swift b/Asterism/AsterismTests/WorkStatusPresentationTests.swiftnew file mode 100644index 0000000..be38c16--- /dev/null+++ b/Asterism/AsterismTests/WorkStatusPresentationTests.swift@@ -0,0 +1,125 @@+import AsterismCore+import ConstellationKit+import Foundation+import Testing++@testable import Asterism++/// The one table the row label, the filter pills, the meta line and the merge+/// picker all read (Q36). Pinned here because four surfaces spelling a status+/// four ways is exactly what the table exists to prevent, and because the UI+/// journeys address these strings by name.+@Suite("Work and reading status presentation")+struct WorkStatusPresentationTests {++ // MARK: - Names (Req 1.1, 2.1)++ @Test("Every work status has a name, and every reading status has one")+ func namesCoverEveryCase() {+ #expect(+ WorkStatus.allCases.map(WorkStatusPresentation.name)+ == ["Ongoing", "Finished", "On hiatus"])+ #expect(+ ReadingStatus.allCases.map(ReadingStatusPresentation.name)+ == ["Reading", "Finished", "Abandoned"])+ }++ // MARK: - Glyphs (Reqs 4.3, 5.1, 5.2, Q27)++ /// The four symbols of Q27, and the two values that draw nothing: a work+ /// still running and a reader still reading are the defaults, and a glyph+ /// on every row would say nothing.+ @Test("Only the non-default values carry a glyph, and each carries its own")+ func glyphsAreDistinctAndDefaultsHaveNone() {+ #expect(WorkStatusPresentation.systemImage(.ongoing) == nil)+ #expect(WorkStatusPresentation.systemImage(.finished) == "flag.checkered")+ #expect(WorkStatusPresentation.systemImage(.hiatus) == "pause.circle")++ #expect(ReadingStatusPresentation.systemImage(.reading) == nil)+ #expect(ReadingStatusPresentation.systemImage(.finished) == "checkmark")+ #expect(ReadingStatusPresentation.systemImage(.abandoned) == "book.closed")++ // Req 4.3: distinct per value across both dimensions — a shared symbol+ // would make "finished work" and "finished reading" one mark.+ let symbols =+ WorkStatus.allCases.compactMap(WorkStatusPresentation.systemImage)+ + ReadingStatus.allCases.compactMap(ReadingStatusPresentation.systemImage)+ #expect(Set(symbols).count == 4)+ }++ /// Q27: no third hue. Work glyphs take the type tag's violet, reading glyphs+ /// the count pill's cyan, so the row's marks stay inside the two accents the+ /// row already uses.+ @Test("Work glyphs are violet and reading glyphs are cyan")+ func huesFollowTheRowsExistingAccents() {+ for status in WorkStatus.allCases {+ #expect(WorkStatusPresentation.hue(status) == AsterismColors.violet)+ }+ for status in ReadingStatus.allCases {+ #expect(ReadingStatusPresentation.hue(status) == AsterismColors.cyan)+ }+ }++ // MARK: - Accessibility labels (Reqs 5.5, 6.2, Q23)++ /// Both dimensions have a value called "Finished", so every label names its+ /// dimension. The same strings are the filter pills' visible text.+ @Test("Every label names its dimension and its value")+ func labelsNameTheirDimension() {+ #expect(+ WorkStatus.allCases.map(WorkStatusPresentation.accessibilityLabel)+ == ["Work: Ongoing", "Work: Finished", "Work: On hiatus"])+ #expect(+ ReadingStatus.allCases.map(ReadingStatusPresentation.accessibilityLabel)+ == ["Reading: Reading", "Reading: Finished", "Reading: Abandoned"])+ }++ // MARK: - Verdict prompts (Req 2.3, Q16)++ /// The prompt is what tells the two verdicts apart — "How was it?" over a+ /// book finished, "Why did you stop?" over one dropped. A work still being+ /// read has no verdict field, so it has no prompt.+ @Test("Each done-reading value has its own prompt, and reading has none")+ func verdictPromptsPerCase() {+ #expect(ReadingStatusPresentation.verdictPrompt(.reading) == nil)+ #expect(ReadingStatusPresentation.verdictPrompt(.finished) == "How was it?")+ #expect(ReadingStatusPresentation.verdictPrompt(.abandoned) == "Why did you stop?")+ // The prompt exists exactly where the field does (Req 2.3).+ for status in ReadingStatus.allCases {+ #expect((ReadingStatusPresentation.verdictPrompt(status) != nil) == status.isDone)+ }+ }++ // MARK: - The duplicate sheet reads the same table (Q54)++ /// `DuplicateResolutionView` carried its own copy of these words from task+ /// 11 until this table existed. The copy is gone; the sheet's arms are the+ /// table's labels, so a re-spelling cannot land on three surfaces and miss+ /// the fourth.+ @Test("The duplicate sheet's status arms are the table's labels")+ @MainActor func duplicateSheetArmsComeFromTheTable() {+ for status in WorkStatus.allCases {+ #expect(+ DuplicateResolutionView.workStatusLine(+ Self.variant(workStatus: status), differing: [.workStatus])+ == WorkStatusPresentation.accessibilityLabel(status))+ }+ for status in ReadingStatus.allCases {+ #expect(+ DuplicateResolutionView.readingStatusLine(+ Self.variant(readingStatus: status), differing: [.readingStatus])+ == ReadingStatusPresentation.accessibilityLabel(status))+ }+ }++ private static func variant(+ workStatus: WorkStatus = .ongoing, readingStatus: ReadingStatus = .reading+ ) -> WorkVariantChoice {+ WorkVariantChoice(+ id: VariantID(rawValue: "work-variant"),+ displayTitle: "Serial", manualTitle: nil, genericNotes: "",+ workURLString: nil, genreTags: [], typeDisplay: .untyped,+ workStatus: workStatus, readingStatus: readingStatus, verdict: "",+ firstCapturedAt: Date(timeIntervalSince1970: 1_800_000_000))+ }+}
diff --git a/Asterism/AsterismTests/WorksFilterPresentationTests.swift b/Asterism/AsterismTests/WorksFilterPresentationTests.swiftindex 0f8275a..b91775f 100644--- a/Asterism/AsterismTests/WorksFilterPresentationTests.swift+++ b/Asterism/AsterismTests/WorksFilterPresentationTests.swift@@ -78,6 +78,74 @@ struct WorksFilterPresentationTests { #expect(options.name(for: .named(WorkTypeName.normalize("Manga"))) == "Manga") } + // MARK: - The two status dimensions (`work-and-reading-status` Req 6.2, Q23)++ /// Both dimensions have a value called "Finished" and the pill row prints+ /// bare names, so an unqualified pill would read "Finished, Finished".+ @Test("A status pill names its dimension as well as its value")+ @MainActor func statusPillsNameTheirDimension() {+ let options = WorksFilterOptions(works: [Self.work()])+ #expect(+ WorksFilterPresentation.activeLabels(+ WorksFilter(workStatus: .finished), options: options) == ["Work: Finished"])+ #expect(+ WorksFilterPresentation.activeLabels(+ WorksFilter(readingStatus: .finished), options: options) == ["Reading: Finished"])+ #expect(+ WorksFilterPresentation.activeLabels(+ WorksFilter(workStatus: .hiatus), options: options) == ["Work: On hiatus"])+ }++ /// Menu order: type, tag, site, then the two statuses, in the order the+ /// pickers sit in the menu.+ @Test("The status pills follow the site pill, in menu order")+ @MainActor func statusPillsFollowTheSite() {+ let options = WorksFilterOptions(works: [+ Self.work(typeDisplay: Self.typed("Manga"), tags: ["shonen"], hostnames: ["a.example"])+ ])+ let filter = WorksFilter(+ type: .named(WorkTypeName.normalize("Manga")), tag: "shonen", hostname: "a.example",+ workStatus: .hiatus, readingStatus: .abandoned)+ #expect(+ WorksFilterPresentation.activeLabels(filter, options: options)+ == ["Manga", "shonen", "a.example", "Work: On hiatus", "Reading: Abandoned"])+ }++ /// Req 6.2: the empty sentence names the filters as the pills do, so the+ /// dead end a status filter reaches explains itself in the same words.+ @Test("The empty sentence carries the qualified status names")+ @MainActor func emptySentenceCarriesQualifiedStatusNames() {+ let options = WorksFilterOptions(works: [Self.work()])+ let labels = WorksFilterPresentation.activeLabels(+ WorksFilter(workStatus: .finished, readingStatus: .abandoned), options: options)+ #expect(+ WorksFilterPresentation.emptyDescription(labels: labels, query: nil)+ == "No works match Work: Finished, Reading: Abandoned.")+ }++ /// Req 6.1's fixed order, addressed the way the existing dimensions are —+ /// one row identifier per value plus the "Any" row.+ @Test("Every status row is addressable, Any included")+ @MainActor func statusRowsAreAddressable() {+ #expect(+ WorkStatus.allCases.map(WorksFilterPresentation.workStatusRowIdentifier) == [+ "works-filter-work-status-ongoing",+ "works-filter-work-status-finished",+ "works-filter-work-status-hiatus",+ ])+ #expect(+ ReadingStatus.allCases.map(WorksFilterPresentation.readingStatusRowIdentifier) == [+ "works-filter-reading-status-reading",+ "works-filter-reading-status-finished",+ "works-filter-reading-status-abandoned",+ ])+ #expect(+ WorksFilterPresentation.anyWorkStatusRowIdentifier == "works-filter-work-status-any")+ #expect(+ WorksFilterPresentation.anyReadingStatusRowIdentifier+ == "works-filter-reading-status-any")+ }+ // MARK: - The empty state (Req 8) @Test("With no query the sentence names the filters alone")
diff --git a/Asterism/AsterismTests/WorksListOptionsTests.swift b/Asterism/AsterismTests/WorksListOptionsTests.swiftindex 88398d8..0329169 100644--- a/Asterism/AsterismTests/WorksListOptionsTests.swift+++ b/Asterism/AsterismTests/WorksListOptionsTests.swift@@ -128,6 +128,70 @@ struct WorksSortTests { #expect(sort.apply(to: []).isEmpty) } }++ // MARK: - Abandoned last (`work-and-reading-status` Req 5.3, Q31)++ private func abandoned(_ index: Int, _ title: String) -> WorkSnapshot {+ TestFixtures.makeWork(+ id: id(index), displayTitle: title,+ entries: [TestFixtures.makeEntry(workID: id(index))],+ readingStatus: .abandoned)+ }++ /// The partition runs last, over whatever the sort produced, so every sort+ /// gets the rule from one line rather than four.+ @Test("Abandoned works follow the rest under every sort")+ func abandonedWorksComeLastUnderEverySort() {+ let works = [work(1, "B"), abandoned(2, "A"), work(3, "C"), abandoned(4, "D")]+ let expected: [WorksSort: [UUID]] = [+ .newest: [id(1), id(3), id(2), id(4)],+ .oldest: [id(3), id(1), id(4), id(2)],+ .aToZ: [id(1), id(3), id(2), id(4)],+ .zToA: [id(3), id(1), id(4), id(2)],+ ]+ for sort in WorksSort.allCases {+ #expect(sort.apply(to: works).map(\.id) == expected[sort], "\(sort)")+ }+ }++ /// Req 5.3's harder half: `.oldest` reverses each *emptiness* section, and+ /// the partition then runs over that result. Both steps are stable, so the+ /// abandoned works trail while keeping the reversed order among themselves —+ /// and the empty works still trail the non-empty ones once the view splits.+ @Test("Abandoned last composes with oldest first's per-section reversal")+ func abandonedLastComposesWithTheOldestReversal() {+ let works = [+ work(1, "A"), abandoned(2, "B"), work(3, "C"), abandoned(4, "D"),+ emptyWork(5, "E"), emptyWork(6, "F"),+ ]+ let sorted = WorksSort.oldest.apply(to: works)+ // The partition is over the whole result, so the abandoned pair trails+ // the empty works here — and the view's own emptiness `filter`, being+ // stable too, puts each section back in the order Req 5.3 asks for.+ #expect(sorted.map(\.id) == [id(3), id(1), id(6), id(5), id(4), id(2)])+ #expect(sorted.filter { !$0.entries.isEmpty }.map(\.id) == [id(3), id(1), id(4), id(2)])+ #expect(sorted.filter { $0.entries.isEmpty }.map(\.id) == [id(6), id(5)])+ }++ /// A partition, not a sort: the abandoned works keep the order the sort gave+ /// them. `partition(by:)` is unstable and would scramble them.+ @Test("The partition is stable among the abandoned works")+ func thePartitionIsStable() {+ let works = [abandoned(3, "C"), abandoned(1, "A"), abandoned(2, "B")]+ #expect(WorksSort.newest.apply(to: works).map(\.id) == [id(3), id(1), id(2)])+ #expect(WorksSort.aToZ.apply(to: works).map(\.id) == [id(1), id(2), id(3)])+ }++ /// The other two reading statuses are one class, not three: a finished read+ /// is still a read the reader did, and only `abandoned` steps back (Req 5.2).+ @Test("Only abandoned steps back — a finished read keeps its place")+ func onlyAbandonedIsMovedBack() {+ let finished = TestFixtures.makeWork(+ id: id(1), displayTitle: "A", entries: [TestFixtures.makeEntry(workID: id(1))],+ readingStatus: .finished)+ let works = [finished, abandoned(2, "B"), work(3, "C")]+ #expect(WorksSort.newest.apply(to: works).map(\.id) == [id(1), id(3), id(2)])+ } } @Suite("Works filter")@@ -138,12 +202,15 @@ struct WorksFilterTests { title: String = "Work", typeDisplay: WorkTypeDisplay = .untyped, tags: [String] = [],- hostnames: [String] = ["example.com"]+ hostnames: [String] = ["example.com"],+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading ) -> WorkSnapshot { TestFixtures.makeWork( id: id(index), displayTitle: title, memberships: TestFixtures.makeMemberships(hostnames),- typeDisplay: typeDisplay, genreTags: tags)+ typeDisplay: typeDisplay, genreTags: tags,+ workStatus: workStatus, readingStatus: readingStatus) } // MARK: - Activity (Req 5)@@ -161,6 +228,20 @@ struct WorksFilterTests { #expect(WorksFilter(type: .untyped).isActive) #expect(WorksFilter(tag: "shonen").isActive) #expect(WorksFilter(hostname: "example.com").isActive)+ #expect(WorksFilter(workStatus: .ongoing).isActive)+ #expect(WorksFilter(readingStatus: .reading).isActive)+ }++ /// The two new dimensions are nil-defaulted like the other three, so every+ /// call site written before V10 still describes an unfiltered list.+ @Test("The init defaults every dimension to nothing chosen")+ func initDefaultsToNothingChosen() {+ let filter = WorksFilter()+ #expect(filter.type == nil)+ #expect(filter.tag == nil)+ #expect(filter.hostname == nil)+ #expect(filter.workStatus == nil)+ #expect(filter.readingStatus == nil) } // MARK: - Type (Q7)@@ -225,25 +306,75 @@ struct WorksFilterTests { #expect(WorksFilter(hostname: "example.com").apply(to: works).isEmpty) } + // MARK: - Work and reading status (`work-and-reading-status` Req 6.1)++ @Test("A work status matches works carrying exactly that value")+ func workStatusMatchesItsValue() {+ let works = [+ work(1, workStatus: .ongoing),+ work(2, workStatus: .finished),+ work(3, workStatus: .hiatus),+ ]+ #expect(WorksFilter(workStatus: .finished).apply(to: works).map(\.id) == [id(2)])+ #expect(WorksFilter(workStatus: .ongoing).apply(to: works).map(\.id) == [id(1)])+ #expect(WorksFilter(workStatus: .hiatus).apply(to: works).map(\.id) == [id(3)])+ }++ @Test("A reading status matches works carrying exactly that value")+ func readingStatusMatchesItsValue() {+ let works = [+ work(1, readingStatus: .reading),+ work(2, readingStatus: .finished),+ work(3, readingStatus: .abandoned),+ ]+ #expect(WorksFilter(readingStatus: .finished).apply(to: works).map(\.id) == [id(2)])+ #expect(WorksFilter(readingStatus: .abandoned).apply(to: works).map(\.id) == [id(3)])+ }++ /// Both dimensions have a value spelled `finished`, and they are separate+ /// questions: a finished work the reader is still reading answers one and+ /// not the other.+ @Test("The two finished values are two different questions")+ func theTwoFinishedValuesAreDistinct() {+ let works = [+ work(1, workStatus: .finished, readingStatus: .reading),+ work(2, workStatus: .ongoing, readingStatus: .finished),+ ]+ #expect(WorksFilter(workStatus: .finished).apply(to: works).map(\.id) == [id(1)])+ #expect(WorksFilter(readingStatus: .finished).apply(to: works).map(\.id) == [id(2)])+ }+ // MARK: - AND across dimensions (Q3) - @Test("The three dimensions are ANDed")+ @Test("The five dimensions are ANDed") func dimensionsAreANDed() { let match = work( 1, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],- hostnames: ["a.example"])+ hostnames: ["a.example"], workStatus: .finished, readingStatus: .abandoned) let wrongTag = work( 2, typeDisplay: typed("Manga", kind: .active), tags: ["seinen"],- hostnames: ["a.example"])+ hostnames: ["a.example"], workStatus: .finished, readingStatus: .abandoned) let wrongSite = work( 3, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],- hostnames: ["b.example"])- let wrongType = work(4, tags: ["shonen"], hostnames: ["a.example"])+ hostnames: ["b.example"], workStatus: .finished, readingStatus: .abandoned)+ let wrongType = work(+ 4, tags: ["shonen"], hostnames: ["a.example"], workStatus: .finished,+ readingStatus: .abandoned)+ let wrongWorkStatus = work(+ 5, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["a.example"], workStatus: .hiatus, readingStatus: .abandoned)+ let wrongReadingStatus = work(+ 6, typeDisplay: typed("Manga", kind: .active), tags: ["shonen"],+ hostnames: ["a.example"], workStatus: .finished, readingStatus: .finished) let filter = WorksFilter( type: .named(WorkTypeName.normalize("Manga")), tag: "shonen",- hostname: "a.example")+ hostname: "a.example", workStatus: .finished, readingStatus: .abandoned) #expect(- filter.apply(to: [match, wrongTag, wrongSite, wrongType]).map(\.id) == [id(1)])+ filter.apply(+ to: [+ match, wrongTag, wrongSite, wrongType, wrongWorkStatus, wrongReadingStatus,+ ]+ ).map(\.id) == [id(1)]) } @Test("A combination nothing matches returns nothing")@@ -276,12 +407,26 @@ struct WorksFilterTests { #expect(pruned.hostname == "a.example") } - @Test("Pruning against an empty snapshot clears every value")+ @Test("Pruning against an empty snapshot clears every open vocabulary") func pruningAgainstEmptyClearsAll() { let filter = WorksFilter(type: .untyped, tag: "t", hostname: "h") #expect(filter.pruned(to: .empty) == WorksFilter()) } + /// Req 6.3, Q30: the two status vocabularies are closed, so a value cannot+ /// stop being offered — pruning them would drop a live question the moment+ /// the last work carrying it left the library.+ @Test("Pruning leaves a status alone, even against an empty snapshot")+ func pruningLeavesStatusesAlone() {+ let filter = WorksFilter(+ type: .untyped, tag: "t", hostname: "h", workStatus: .hiatus,+ readingStatus: .abandoned)+ let pruned = filter.pruned(to: .empty)+ #expect(pruned.workStatus == .hiatus)+ #expect(pruned.readingStatus == .abandoned)+ #expect(pruned == WorksFilter(workStatus: .hiatus, readingStatus: .abandoned))+ }+ @Test("A filter the options still cover is returned unchanged") func pruningKeepsCoveredFilter() { let options = WorksFilterOptions(works: [work(1, tags: ["t"], hostnames: ["h"])])
diff --git a/Asterism/AsterismTests/WorksRowPresentationTests.swift b/Asterism/AsterismTests/WorksRowPresentationTests.swiftindex 029a70a..57b8821 100644--- a/Asterism/AsterismTests/WorksRowPresentationTests.swift+++ b/Asterism/AsterismTests/WorksRowPresentationTests.swift@@ -36,6 +36,72 @@ struct WorksRowPresentationTests { #expect(WorksRowPresentation.openLabel(for: work) == "Open Work Orphan") } + // MARK: - Status clauses (`work-and-reading-status` Req 5.5, Q32)++ /// `WorkRow` is one button, so a glyph's own label is never spoken: the+ /// row's single label is the only channel the marks have. The clauses are+ /// joined with ". " because the label already lists hostnames with ", ".+ @Test("The status clauses trail the site list, separated from it by a period")+ @MainActor func statusClausesTrailTheSiteList() {+ let work = TestFixtures.makeWork(+ displayTitle: "Zephyr",+ memberships: TestFixtures.makeMemberships(["a.example", "b.example"]),+ workStatus: .finished, readingStatus: .abandoned)++ #expect(+ WorksRowPresentation.openLabel(for: work)+ == "Open Work Zephyr from a.example, b.example. Work: Finished. "+ + "Reading: Abandoned")+ }++ /// The clauses mirror the glyphs (Req 5.1): a work still running and a+ /// reader still reading are the defaults, so they add nothing to the label.+ @Test("A work on both defaults adds no clause")+ @MainActor func defaultsAddNoClause() {+ let work = TestFixtures.makeWork(displayTitle: "Zephyr", hostname: "a.example")+ #expect(WorksRowPresentation.openLabel(for: work) == "Open Work Zephyr from a.example")+ }++ @Test("Each dimension contributes its clause on its own")+ @MainActor func eachDimensionContributesAlone() {+ let hiatus = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example", workStatus: .hiatus)+ #expect(+ WorksRowPresentation.openLabel(for: hiatus)+ == "Open Work Zephyr from a.example. Work: On hiatus")++ let read = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example", readingStatus: .finished)+ #expect(+ WorksRowPresentation.openLabel(for: read)+ == "Open Work Zephyr from a.example. Reading: Finished")+ }++ /// Req 5.2: the abandoned row's reduced emphasis is colour and opacity, so+ /// the title element carries the state in words as well.+ @Test("The title element carries the same clauses")+ @MainActor func theTitleElementCarriesTheClauses() {+ let abandoned = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example", readingStatus: .abandoned)+ #expect(WorksRowPresentation.titleLabel(for: abandoned) == "Zephyr. Reading: Abandoned")++ let plain = TestFixtures.makeWork(displayTitle: "Zephyr", hostname: "a.example")+ #expect(WorksRowPresentation.titleLabel(for: plain) == "Zephyr")+ }++ /// The UI journeys read a listed title as the substring before " from ",+ /// so the clauses must stay behind the site list.+ @Test("The clauses leave the title parser's substring alone")+ @MainActor func theTitleParserIsUnaffected() throws {+ let work = TestFixtures.makeWork(+ displayTitle: "Zephyr", hostname: "a.example", workStatus: .finished,+ readingStatus: .abandoned)+ let label = WorksRowPresentation.openLabel(for: work)+ let opened = try #require(label.range(of: "Open Work "))+ let site = try #require(label.range(of: " from "))+ #expect(String(label[opened.upperBound..<site.lowerBound]) == "Zephyr")+ }+ // MARK: - The dismiss pill (Req 5.5) /// The record is of an unordered **pair**, so what one pill names is the
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 982c548..35f4eeb 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -305,6 +305,22 @@ final class AccessibilityJourneyUITests: XCTestCase { scrollToElement(row, attempts: 8) assertContentControl(row, named: "A work row under the filter pills") + // `work-and-reading-status` Req 10.2: a status filter is chosen through+ // the same menu at this size, and the row it leaves wears a work-status+ // glyph and a reading-status glyph — the row that has to stay hittable.+ // ANDed with the site filter above, it is Zephyr Court alone.+ chooseWorksOption("works-filter-work-status-finished", labelled: "Finished", in: app)+ let marked = app.buttons.matching(+ NSPredicate(format: "label BEGINSWITH %@", "Open Work Zephyr Court")).firstMatch+ XCTAssertTrue(+ marked.waitForExistence(timeout: 15),+ "A status filter narrows the list at largest Dynamic Type")+ scrollToElement(marked, attempts: 8)+ assertContentControl(marked, named: "A row wearing both status glyphs")+ XCTAssertTrue(+ marked.label.hasSuffix(". Work: Finished. Reading: Finished"),+ "Req 5.5: the row names both marks — was \(marked.label)")+ // The menu still opens with the pills on screen, which is the state a // reader is in when they go back to change or clear the filter. menu.tap()@@ -313,6 +329,134 @@ final class AccessibilityJourneyUITests: XCTestCase { sortRow.waitForExistence(timeout: 10), "The menu reopens over a filtered list") } + /// `work-and-reading-status` Req 10.2, the edit-mode half: at the largest+ /// Dynamic Type size the two status capsules and the verdict field are still+ /// reachable and operable, and Req 3.1's dialog still offers all three of+ /// its actions — the third of them as the platform's own cancel rather than+ /// the declared button, see the comment at the assertion.+ ///+ /// The capsules are the risk. `ConstellationSegmentedControl` lays its three+ /// segments across a row and falls back to a stacked layout through+ /// `ViewThatFits` when they no longer fit — at `accessibility5` on a phone+ /// they do not, so this case is the one that walks the stacked arm. Marrow+ /// Lane is the fixture's abandoned work, so the verdict field is on screen+ /// before anything is tapped and choosing `finished` reading raises the+ /// dialog.+ @MainActor+ func testTheStatusControlsAndDialogStayOperableAtLargestDynamicType() {+ launchSeeded(+ scenario: "seeded-works-options",+ extraArguments: [+ "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"+ ]+ )++ let works = app.tabControl(.works)+ XCTAssertTrue(works.waitForExistence(timeout: 30), "The Works tab is reachable")+ works.tap()+ XCTAssertTrue(+ app.collectionViews["works-list"].waitForExistence(timeout: 15),+ "Works lists the seeded library")++ let abandoned = app.buttons.matching(+ NSPredicate(format: "label BEGINSWITH %@", "Open Work Marrow Lane")).firstMatch+ XCTAssertTrue(abandoned.waitForExistence(timeout: 15), "The abandoned work is listed")+ scrollToElement(abandoned, attempts: 8)+ assertContentControl(abandoned, named: "The abandoned row at largest Dynamic Type")+ abandoned.tap()++ let edit = app.buttons["work-detail-edit-button"]+ XCTAssertTrue(edit.waitForExistence(timeout: 20), "The work page opens")+ assertSystemControl(edit, named: "The editor at largest Dynamic Type")+ edit.tap()+ XCTAssertTrue(+ app.textFields["work-detail-title-field"].waitForExistence(timeout: 15),+ "The editor is open")++ // Req 10.1/10.2: every new control carries an identifier and a label,+ // and at this size it still owns a 44 pt target.+ for identifier in [+ "work-detail-work-status-ongoing", "work-detail-work-status-finished",+ "work-detail-work-status-hiatus", "work-detail-reading-status-reading",+ "work-detail-reading-status-abandoned",+ ] {+ let segment = app.buttons[identifier]+ // Two scrolls, and both are needed: the editor is a lazy `List`, so+ // a row below the fold is not in the tree at all until it is+ // scrolled in — and at this size the stacked capsules are tall+ // enough that a segment can exist while still sitting under the+ // keyboard-free bottom of the window.+ scrollUntilPresent(segment, in: app, "The editor offers \(identifier) at this size")+ scrollToElement(segment, attempts: 8)+ assertContentControl(segment, named: "\(identifier) at largest Dynamic Type")+ }++ let verdict = app.anyElement("work-detail-verdict-field")+ scrollUntilPresent(verdict, in: app, "The verdict field is reachable at this size")+ scrollToElement(verdict, attempts: 8)+ XCTAssertEqual(+ verdict.label, "Why did you stop?",+ "Req 10.1: the field is labelled by its prompt rather than its placeholder")++ // Req 3.1's dialog, at the size where three actions are most likely to+ // clip. The capsule's own segment is tapped to raise it, which is also+ // the assertion that the segment is operable and not merely present.+ scrollUntilTappableAndTap(+ app.buttons["work-detail-reading-status-finished"], in: app,+ "The reading-status capsule is operable at largest Dynamic Type")+ for identifier in [+ "work-detail-finished-mark-work", "work-detail-finished-abandon",+ ] {+ let action = app.dialogButton(identifier)+ XCTAssertTrue(+ action.waitForExistence(timeout: 10),+ "Req 10.2: the dialog presents \(identifier) at largest Dynamic Type")+ assertSystemControl(action, named: "\(identifier) in the dialog")+ }+ // The third action is the one the platform draws for itself: iOS renders+ // this dialog as an anchored popover, and a popover-presented dialog+ // **omits** the declared cancel button because a tap outside is the+ // platform's cancel. Measured at `accessibility5` here and at the+ // default size on the phone — `work-detail-finished-cancel` exists at+ // neither, the popover holds exactly the two action buttons — and the+ // same fact `declineConfirmationDialog` records for the delete dialog.+ // So the third action is asserted as what it is, a way out that works,+ // through the helper that prefers the identified control wherever the+ // platform draws one; the assertions under the call are what say the way+ // out was a *cancel*.+ declineConfirmationDialog(+ cancel: "work-detail-finished-cancel", dismissing: "work-detail-finished-mark-work",+ in: app)++ // Req 3.2: what makes that a *cancel* rather than a dismissal is that+ // the drafts are where they were — the same pair `WorkDetailStatusUITests`+ // asserts at compact width. Without it the check above says only that+ // the dialog closed, which it also does when an action is taken.+ let abandonedSegment = app.buttons["work-detail-reading-status-abandoned"]+ scrollUntilPresent(+ abandonedSegment, in: app, "The reading capsule is back on screen after the cancel")+ XCTAssertTrue(+ abandonedSegment.isSelected,+ "Req 3.2: cancelling restores the reading status the picker showed")+ let ongoingSegment = app.buttons["work-detail-work-status-ongoing"]+ scrollUntilPresent(ongoingSegment, in: app, "…as is the work-status capsule")+ XCTAssertTrue(+ ongoingSegment.isSelected, "…and the work status was never touched")++ // Req 10.2 for the verdict field, which the read above only labels: the+ // same operability the five segments are held to, and a tap to prove it+ // is reachable and not merely laid out. Last in the case deliberately —+ // the tap raises the keyboard, which would stand over the capsule the+ // dialog is raised from.+ scrollUntilPresent(verdict, in: app, "The verdict field is still on screen")+ scrollToElement(verdict, attempts: 8)+ assertContentControl(verdict, named: "The verdict field at largest Dynamic Type")+ verdict.tap()+ XCTAssertEqual(+ verdict.label, "Why did you stop?",+ "…and tapping it changes neither the field nor what it is asking for")+ }+ // MARK: - Constellation visual pass (Reqs 8–11) /// The Works tab and Work detail in dark, walked through the surfaces the
diff --git a/Asterism/AsterismUITests/UIJourneySupport.swift b/Asterism/AsterismUITests/UIJourneySupport.swiftindex c7a5c8d..26a1b55 100644--- a/Asterism/AsterismUITests/UIJourneySupport.swift+++ b/Asterism/AsterismUITests/UIJourneySupport.swift@@ -154,15 +154,64 @@ extension XCTestCase { if cancel.exists, cancel.isHittable { cancel.tap() } else {- // Mid-screen, below the popover and above the tab bar; the dimming- // layer absorbs the tap rather than passing it through.- app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.55)).tap()+ app.coordinate(withNormalizedOffset: outsidePopoverOffset(in: app)).tap() } waitUntilGone( app.dialogButton(witnessIdentifier), "The declined dialog closes", timeout: 10, file: file, line: line) } + /// Where to tap to decline a popover-presented dialog: outside the popover,+ /// on the dimming layer, which absorbs the tap rather than passing it+ /// through.+ ///+ /// Mid-screen — below the popover and above the tab bar — is the answer at+ /// the ordinary text sizes, and is what every caller has always got. **It+ /// stops being the answer at the accessibility sizes.** The popover then+ /// fills all but a sliver of the window: measured at `accessibility5` on a+ /// 402×874 window, the popover is 366×758 at y 72, and the mid-screen point+ /// (201, 480) is inside the dialog's first action button. A tap there closes+ /// the dialog by *taking* that action, so `waitUntilGone` is satisfied and+ /// the decline quietly does the opposite of declining — which is how the+ /// largest-Dynamic-Type status journey came up green while both statuses had+ /// been written to `finished`.+ ///+ /// So the default point is kept wherever the popover does not contain it —+ /// every caller at the ordinary sizes is unaffected — and only where it does+ /// is the tap moved into one of the margins the popover leaves.+ ///+ /// **The band above the popover is not one of them**, though at+ /// `accessibility5` it is the tallest: those 72 pt are the status bar, which+ /// is a system window over the app, and a tap there never reaches the+ /// dimming layer at all (measured — the dialog stayed up). The margin below+ /// is 44 pt of app, tab bar included, and the dimming layer is over all of+ /// it. The sides are tried after it and the top last, so a popover anchored+ /// against the bottom still has somewhere to go.+ private func outsidePopoverOffset(in app: XCUIApplication) -> CGVector {+ let midScreen = CGVector(dx: 0.5, dy: 0.55)+ let popover = app.popovers.firstMatch+ let window = app.windows.firstMatch+ guard popover.exists, window.exists, window.frame.height > 0 else { return midScreen }+ let frame = window.frame+ let point = CGPoint(+ x: frame.minX + frame.width * midScreen.dx,+ y: frame.minY + frame.height * midScreen.dy)+ guard popover.frame.contains(point) else { return midScreen }++ // Each margin as the point at its middle, with the span that has to+ // clear a finger for it to be usable at all.+ let dialog = popover.frame+ let margins: [(span: CGFloat, point: CGPoint)] = [+ (frame.maxY - dialog.maxY, CGPoint(x: point.x, y: (dialog.maxY + frame.maxY) / 2)),+ (dialog.minX - frame.minX, CGPoint(x: (frame.minX + dialog.minX) / 2, y: point.y)),+ (frame.maxX - dialog.maxX, CGPoint(x: (dialog.maxX + frame.maxX) / 2, y: point.y)),+ (dialog.minY - frame.minY, CGPoint(x: point.x, y: (frame.minY + dialog.minY) / 2)),+ ]+ guard let chosen = margins.first(where: { $0.span >= 24 })?.point else { return midScreen }+ return CGVector(+ dx: (chosen.x - frame.minX) / frame.width, dy: (chosen.y - frame.minY) / frame.height)+ }+ /// Scrolls entry detail's form up by one screenful. /// /// **Not `app.swipeUp()`.** That swipes from the middle of the screen, which
diff --git a/Asterism/AsterismUITests/WideLayoutUITests.swift b/Asterism/AsterismUITests/WideLayoutUITests.swiftindex a8afb45..bb7d109 100644--- a/Asterism/AsterismUITests/WideLayoutUITests.swift+++ b/Asterism/AsterismUITests/WideLayoutUITests.swift@@ -444,6 +444,46 @@ final class WideLayoutUITests: XCTestCase { waitFor(app.anyElement("sidebar-recent"), "And the sidebar stays where it is") } + /// `work-and-reading-status` Req 10.3: the statuses are the wide layout's+ /// too, with no platform-specific omission.+ ///+ /// The work page's meta line is where the two dimensions are stated in full+ /// (Req 4.1), and it is the one status surface whose layout differs between+ /// the phone and the iPad — on the phone it is a pushed screen's header, here+ /// it is the detail column's. So this case reads the two items and checks+ /// they are laid out inside that column rather than across the pane, which is+ /// the shape a push takes here (Req 1.7 and the Diagnostics case above).+ ///+ /// Zephyr Court is the fixture's work that is finished on both sides, so both+ /// items are drawn; the row-level glyphs and the filters are the phone+ /// suites', unchanged.+ func testTheWorkPageNamesBothStatusesInTheDetailColumn() {+ launch("seeded-works-options", orientation: .landscapeLeft)+ waitForLibrary()++ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ waitFor(+ app.buttons.matching(+ NSPredicate(format: "label BEGINSWITH %@", "Open Work Zephyr Court")).firstMatch,+ "The doubly marked work is listed"+ ).tap()+ waitFor(app.anyElement("work-detail-pulse"), "The work fills the detail column")++ let detailColumn = waitFor(+ app.anyElement("wide-detail-column"), "The detail column is laid out")+ let workItem = waitFor(+ app.anyElement("work-detail-status-work"),+ "Req 4.1: the meta line names the work status on iPad too")+ XCTAssertEqual(workItem.label, "Work: Finished", "…with its dimension (Q23)")+ let readingItem = waitFor(+ app.anyElement("work-detail-status-reading"), "…and the reading status beside it")+ XCTAssertEqual(readingItem.label, "Reading: Finished", "…qualified the same way")++ assertInsideColumn(workItem, column: detailColumn, what: "The work-status meta item")+ assertInsideColumn(readingItem, column: detailColumn, what: "The reading-status meta item")+ }+ func testSelectingStatsFillsThePaneWithTheSidebarStillShowing() { launch("seeded-taught", orientation: .landscapeLeft) waitForLibrary()
diff --git a/Asterism/AsterismUITests/WorkDetailStatusUITests.swift b/Asterism/AsterismUITests/WorkDetailStatusUITests.swiftnew file mode 100644index 0000000..3b767d8--- /dev/null+++ b/Asterism/AsterismUITests/WorkDetailStatusUITests.swift@@ -0,0 +1,256 @@+import XCTest++/// The two statuses and the verdict on the work page (`work-and-reading-status`+/// Reqs 3.1, 3.2, 4.1, 4.2, 5.4), driven from app launch.+///+/// The transitions themselves are `WorkDetailModel`'s and have their own unit+/// tests; what only a journey can prove is that the capsules are reachable in+/// edit mode, that choosing `finished` on an unfinished work actually raises the+/// dialog on screen, that its buttons write what they say, and that a committed+/// edit comes back as meta-line items and a verdict paragraph the reader can+/// see.+///+/// `seeded-works-options` is the fixture, shared with `WorksListOptionsUITests`+/// so one seeded scenario carries the whole feature: **Marrow Lane** is+/// abandoned with a verdict and is the work this suite edits, **Zephyr Court**+/// is finished on both sides, **Ashfall** is on hiatus, and **Quill Harbour** is+/// on the defaults.+final class WorkDetailStatusUITests: XCTestCase {+ let app = XCUIApplication()++ override func setUp() {+ continueAfterFailure = false+ XCUIDevice.shared.orientation = .portrait+ terminateAndWaitForExit(app)+ }++ override func tearDown() {+ terminateAndWaitForExit(app)+ }++ // MARK: - Driving++ private func launch() {+ app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-works-options"+ app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ app.launch()+ }++ private var workRows: XCUIElementQuery {+ app.elements(withIdentifierPrefix: "work-row-")+ }++ /// One row, by the work it opens: the `work-row-` identifiers carry a uuid+ /// the test cannot know, so the label is where the title is.+ private func row(titled title: String) -> XCUIElement {+ workRows.matching(NSPredicate(format: "label BEGINSWITH %@", "Open Work \(title) "))+ .firstMatch+ }++ private func openWork(_ title: String) {+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ waitFor(row(titled: title), "\(title) is listed").tap()+ waitFor(app.anyElement("work-detail-pulse"), "\(title) opens", timeout: 20)+ }++ /// Decision 5 of `polish-and-export`: the fields live in edit mode, so every+ /// journey that wants a status capsule enters the editor first.+ private func enterEditMode() {+ waitFor(app.buttons["work-detail-edit-button"], "View mode offers the editor").tap()+ waitFor(app.textFields["work-detail-title-field"], "The editor is open")+ }++ // MARK: - The dialog and the round trip (Reqs 3.1, 3.2, 4.1, 4.2)++ /// The journey the feature is named for: an abandoned work the reader+ /// decides they did finish after all.+ ///+ /// Marrow Lane is stored `ongoing` / `abandoned`, so choosing `finished`+ /// reading raises Req 3.1's dialog rather than writing — and "Mark the work+ /// finished too" is the arm that records both sides at the moment the reader+ /// knows them. The commit then has to come back as two meta-line items and+ /// the verdict paragraph, which is Req 4.1 and 4.2.+ func testMarkingAnAbandonedWorkFinishedMeetsTheDialogAndShowsBothStatuses() {+ launch()+ openWork("Marrow Lane")+ enterEditMode()++ // The stored pair, as the editor shows it before anything is touched.+ let abandoned = app.buttons["work-detail-reading-status-abandoned"]+ scrollUntilPresent(abandoned, in: app, "The editor offers the reading-status capsule")+ XCTAssertTrue(abandoned.isSelected, "The editor opens on the stored reading status")+ XCTAssertTrue(+ app.buttons["work-detail-work-status-ongoing"].isSelected,+ "…and on the stored work status")+ // Req 2.3: the verdict field is present because the work is done being+ // read, under the prompt that says which verdict is being asked for.+ let verdictField = app.anyElement("work-detail-verdict-field")+ waitFor(verdictField, "An abandoned work shows its verdict field")+ XCTAssertEqual(+ verdictField.label, "Why did you stop?",+ "Q16: the prompt is the field's label, not its placeholder")++ // Req 3.1: `finished` reading on a work that is not finished asks.+ scrollUntilTappableAndTap(+ app.buttons["work-detail-reading-status-finished"], in: app,+ "The reading-status capsule offers Finished")+ let markWork = waitFor(+ app.dialogButton("work-detail-finished-mark-work"),+ "Req 3.1: the dialog offers marking the work finished too")+ waitFor(+ app.dialogButton("work-detail-finished-abandon"),+ "…using abandoned instead")+ markWork.tap()++ // Req 3.2's first arm: both drafts move, and the verdict's prompt turns+ // into the finished one without the text going anywhere.+ let finishedWork = app.buttons["work-detail-work-status-finished"]+ scrollUntilPresent(finishedWork, in: app, "The work-status capsule is still on screen")+ let bothFinished = XCTNSPredicateExpectation(+ predicate: NSPredicate { _, _ in+ finishedWork.isSelected+ && self.app.buttons["work-detail-reading-status-finished"].isSelected+ }, object: nil)+ XCTAssertEqual(+ XCTWaiter().wait(for: [bothFinished], timeout: 10), .completed,+ "Req 3.2: marking the work finished sets both drafts to finished")+ XCTAssertEqual(+ app.anyElement("work-detail-verdict-field").label, "How was it?",+ "Req 2.3: the prompt follows the reading status")++ // Commit. The pair no longer violates the rule, so the commit-time check+ // (Req 3.4) has nothing to ask and the editor closes on the first tap.+ waitFor(app.buttons["work-detail-save-button"], "The checkmark commits the editor").tap()+ waitUntilGone(+ app.textFields["work-detail-title-field"], "A committed editor returns to view mode")+ waitFor(app.anyElement("work-detail-pulse"), "View mode is back")++ // Req 4.1: two further items on the meta line, each a glyph with its name.+ let workItem = waitFor(+ app.anyElement("work-detail-status-work"), "The meta line names the work status")+ XCTAssertEqual(workItem.label, "Work: Finished", "…with its dimension (Q23)")+ let readingItem = waitFor(+ app.anyElement("work-detail-status-reading"),+ "…and the reading status beside it")+ XCTAssertEqual(readingItem.label, "Reading: Finished", "…qualified the same way")++ // Req 4.2: the verdict the work already carried is still the reader's,+ // and it is shown now that the status is done reading again.+ let verdict = app.anyElement("work-detail-verdict")+ scrollUntilPresent(verdict, in: app, "The verdict is shown as a paragraph")+ // The paragraph is an accessibility *container* (`children: .contain`),+ // so the words are on the text inside it rather than on the element the+ // identifier is on. Scoped to that container's own descendants rather+ // than asked of the app: an app-wide match says only that the words are+ // somewhere on screen, which they would be if the paragraph were drawn+ // anywhere else — or not drawn at all and the editor still open.+ let verdictTexts = verdict.descendants(matching: .staticText)+ XCTAssertTrue(+ verdictTexts.matching(NSPredicate(format: "label CONTAINS %@", "fog plot"))+ .firstMatch.exists,+ "…and it is the text the work already held, kept through the status change")+ XCTAssertTrue(+ verdictTexts.matching(NSPredicate(format: "label == %@", "Verdict"))+ .firstMatch.exists,+ "Req 4.2: the paragraph is captioned, so the words read as the verdict "+ + "rather than as one more note")+ }++ /// Req 3.2's third action. Cancelling has to leave the capsule exactly where+ /// it was, because the transition never wrote anything: the draft stays put+ /// while the dialog is up, which is what makes cancel free.+ func testCancellingTheFinishedReadingDialogLeavesTheDraftAlone() {+ launch()+ openWork("Marrow Lane")+ enterEditMode()++ scrollUntilTappableAndTap(+ app.buttons["work-detail-reading-status-finished"], in: app,+ "The reading-status capsule offers Finished")+ waitFor(+ app.dialogButton("work-detail-finished-mark-work"), "The dialog is up")+ declineConfirmationDialog(+ cancel: "work-detail-finished-cancel", dismissing: "work-detail-finished-mark-work",+ in: app)++ let abandoned = app.buttons["work-detail-reading-status-abandoned"]+ scrollUntilPresent(abandoned, in: app, "The editor is still on screen")+ XCTAssertTrue(+ abandoned.isSelected,+ "Req 3.2: cancelling restores the reading status the picker showed")+ XCTAssertTrue(+ app.buttons["work-detail-work-status-ongoing"].isSelected,+ "…and the work status was never touched")+ }++ /// Req 3.3, the transition with no dialog: a work that stops being finished+ /// cannot leave a reader "finished" with it, so the reading status steps back+ /// to `reading` and the verdict field goes with it (Req 2.5 keeps the text).+ func testUnfinishingTheWorkStepsTheReadingStatusBack() {+ launch()+ openWork("Zephyr Court")+ enterEditMode()++ let hiatus = app.buttons["work-detail-work-status-hiatus"]+ scrollUntilTappableAndTap(hiatus, in: app, "The work-status capsule offers On hiatus")++ let steppedBack = XCTNSPredicateExpectation(+ predicate: NSPredicate { _, _ in+ self.app.buttons["work-detail-reading-status-reading"].isSelected+ }, object: nil)+ XCTAssertEqual(+ XCTWaiter().wait(for: [steppedBack], timeout: 10), .completed,+ "Req 3.3: an unfinished work cannot be finished reading")+ XCTAssertFalse(+ app.dialogButton("work-detail-finished-mark-work").exists,+ "…and the step back is not a question")+ waitUntilGone(+ app.anyElement("work-detail-verdict-field"),+ "Req 2.3: the verdict field goes with the done-reading status")+ }++ // MARK: - The merge destination picker (Req 5.4)++ /// Req 5.4/Q32: the picker draws the library's own row, so its rows wear the+ /// same glyphs — and a picker row is one button, so the marks reach a reader+ /// who cannot see them only through the row's own label (Q59 puts the+ /// clauses after the note count).+ func testTheMergePickerNamesEachDestinationsStatuses() {+ launch()+ openWork("Ashfall")+ enterEditMode()+ scrollUntilTappableAndTap(+ app.buttons["work-detail-merge-button"], in: app, "The editor offers Merge")++ let destinations = app.elements(withIdentifierPrefix: "merge-destination-")+ waitFor(destinations.firstMatch, "The picker lists the other works", timeout: 20)+ let labels = (0..<destinations.count).map { destinations.element(boundBy: $0).label }++ // Both suffix matches depend on the destination being **available**.+ // Q59 has `destinationLabel` append the refusal *after* the status+ // clauses, because a refusal is a finished sentence and has to stay the+ // last thing the row says — so an unavailable row ends on+ // "…before this Work can be merged into." and `hasSuffix` fails for a+ // reason that has nothing to do with the statuses. What keeps these rows+ // available is the fixture: `seeded-works-options` quarantines no+ // hostname and leaves no Work in differing copies, which are the two+ // things `unavailableMessage(for:)` refuses on.+ XCTAssertTrue(+ labels.contains { $0.hasPrefix("Merge into Marrow Lane") && $0.hasSuffix(". Reading: Abandoned") },+ "An abandoned destination says so — was \(labels)")+ XCTAssertTrue(+ labels.contains {+ $0.hasPrefix("Merge into Zephyr Court")+ && $0.hasSuffix(". Work: Finished. Reading: Finished")+ },+ "…and a doubly marked one names both dimensions — was \(labels)")+ XCTAssertTrue(+ labels.contains { $0.hasPrefix("Merge into Quill Harbour") && !$0.contains("Reading:") },+ "…while a destination on both defaults reads as it always did — was \(labels)")++ waitFor(app.buttons["merge-cancel-button"], "The sheet offers the way out").tap()+ waitUntilGone(destinations.firstMatch, "Cancelling closes the picker")+ }+}
diff --git a/Asterism/AsterismUITests/WorksListOptionsUITests.swift b/Asterism/AsterismUITests/WorksListOptionsUITests.swiftindex 797af0e..0b64f69 100644--- a/Asterism/AsterismUITests/WorksListOptionsUITests.swift+++ b/Asterism/AsterismUITests/WorksListOptionsUITests.swift@@ -11,11 +11,17 @@ import XCTest /// blank list. /// /// `seeded-works-options` is the fixture with something to sort: **Marrow-/// Lane** (alpha.test, tagged `mystery`, untyped), **Ashfall** (beta.test,-/// wearing the removed `webtoon` type) and **Zephyr Court** (alpha.test,-/// `novel`) captured in that order — so the date order is the reverse of it and-/// disagrees with the alphabet at every position — plus the entry-less **Quill-/// Harbour** (beta.test) and one unattached entry.+/// Lane** (alpha.test, tagged `mystery`, untyped, reading `abandoned` with a+/// verdict), **Ashfall** (beta.test, wearing the removed `webtoon` type, work+/// status `hiatus`) and **Zephyr Court** (alpha.test, `novel`, finished on both+/// sides with a verdict) captured in that order — so the date order is the+/// reverse of it and disagrees with the alphabet at every position — plus the+/// entry-less **Quill Harbour** (beta.test, both statuses on their defaults)+/// and one unattached entry.+///+/// `work-and-reading-status` rides on that one scenario rather than a second+/// fixture, which is why the four order constants below sink Marrow Lane: Req+/// 5.3's abandoned-last partition applies to every sort. final class WorksListOptionsUITests: XCTestCase { let app = XCUIApplication() @@ -23,10 +29,17 @@ final class WorksListOptionsUITests: XCTestCase { /// from every other one, so an assertion here cannot pass against the wrong /// sort — and the empty work moves out of its own trailing section under /// the title sorts (Req 2).+ ///+ /// **Marrow Lane is last of its section in all four**+ /// (`work-and-reading-status` Req 5.3, Q31): the partition runs after the+ /// ordering and before the view splits the sections, and both steps are+ /// stable, so the abandoned work sinks inside the section it was already in+ /// while everything else keeps the sort's own order. Under Newest first it+ /// was already there, which is why only three of the four constants moved. private let newestOrder = ["Zephyr Court", "Ashfall", "Marrow Lane", "Quill Harbour"]- private let oldestOrder = ["Marrow Lane", "Ashfall", "Zephyr Court", "Quill Harbour"]- private let aToZOrder = ["Ashfall", "Marrow Lane", "Quill Harbour", "Zephyr Court"]- private let zToAOrder = ["Zephyr Court", "Quill Harbour", "Marrow Lane", "Ashfall"]+ private let oldestOrder = ["Ashfall", "Zephyr Court", "Marrow Lane", "Quill Harbour"]+ private let aToZOrder = ["Ashfall", "Quill Harbour", "Zephyr Court", "Marrow Lane"]+ private let zToAOrder = ["Zephyr Court", "Quill Harbour", "Ashfall", "Marrow Lane"] override func setUp() { continueAfterFailure = false@@ -40,9 +53,16 @@ final class WorksListOptionsUITests: XCTestCase { // MARK: - Driving - private func launch() {+ /// `textSize` is a `UIContentSizeCategory` name — the+ /// `UICTContentSizeCategoryAccessibility{M,L,XL,XXL,XXXL}` spelling, since+ /// an unrecognised name is not an error and silently launches at the default+ /// size (`WideLayoutAccessibilityUITests` measured that).+ private func launch(textSize: String? = nil) { app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-works-options" app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+ if let textSize {+ app.launchArguments += ["-UIPreferredContentSizeCategoryName", textSize]+ } app.launch() } @@ -134,6 +154,11 @@ final class WorksListOptionsUITests: XCTestCase { assertListed(aToZOrder, "A to Z draws one alphabetical section") chooseOption("works-sort-zToA", labelled: "Z to A") assertListed(zToAOrder, "Z to A is that alphabet reversed")+ // `work-and-reading-status` Req 5.3: whichever of the four is on, the+ // abandoned work is the last of the works that have notes.+ XCTAssertEqual(+ listedTitles().last, "Marrow Lane",+ "The abandoned work sinks under every sort") chooseOption("works-sort-newest", labelled: "Newest first") assertListed(newestOrder, "…and the reader can put it back") @@ -246,4 +271,237 @@ final class WorksListOptionsUITests: XCTestCase { app.elements(withIdentifierPrefix: "recent-entry-").count, 4, "Three attached chapters and the unattached note") }++ // MARK: - `work-and-reading-status`: the statuses on the list (Reqs 5, 6)++ /// Req 5.3's other two clauses — the partition holds **with a query** and+ /// **with a filter** — and Req 5.2/5.5's abandoned row saying so in words.+ ///+ /// Both narrowings are asserted under A to Z rather than Newest first,+ /// because that is where the partition is visible: under the date sorts+ /// Marrow Lane is the oldest capture and would trail the list anyway, so a+ /// missing partition would pass. Under the alphabet it sits second, and only+ /// Req 5.3 moves it.+ func testTheAbandonedWorkSinksUnderAQueryAndAFilterAndSaysSo() {+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")++ // Req 5.5: the knock-down is colour and opacity, which XCUI cannot read+ // and a reader may not have. The words are the assertable channel, and+ // the requirement is that they are there.+ let abandoned = row(titled: "Marrow Lane")+ waitFor(abandoned, "The abandoned work is listed")+ XCTAssertTrue(+ abandoned.label.hasSuffix(". Reading: Abandoned"),+ "Req 5.5: the row says it is abandoned — was \(abandoned.label)")+ let marked = row(titled: "Zephyr Court")+ XCTAssertTrue(+ marked.label.hasSuffix(". Work: Finished. Reading: Finished"),+ "…and a row wearing both marks names both — was \(marked.label)")+ XCTAssertEqual(+ row(titled: "Quill Harbour").label, "Open Work Quill Harbour from beta.test",+ "A work on both defaults reads exactly as it did before the statuses existed")++ chooseOption("works-sort-aToZ", labelled: "A to Z")+ assertListed(aToZOrder, "A to Z sinks the abandoned work to the end")++ // With a filter: alpha.test is Marrow Lane and Zephyr Court, which the+ // alphabet on its own would order the other way round.+ chooseOption("works-filter-site-alpha.test", labelled: "alpha.test")+ assertListed(+ ["Zephyr Court", "Marrow Lane"],+ "Req 5.3: the partition holds with a filter on top of the sort")+ app.anyElement("works-filter-clear").tap()+ assertListed(aToZOrder, "The filter is cleared before the query goes on")++ // And with a query: the title search keeps three of the four, and the+ // abandoned one is still last of them. Typed last, so the field never+ // has to be cleared again.+ let field = searchField(in: app)+ field.tap()+ field.typeText("a")+ assertListed(+ ["Ashfall", "Quill Harbour", "Marrow Lane"],+ "Req 5.3: the partition holds with a query on top of the sort")+ }++ /// Req 6.1's two new dimensions, Req 6.2's qualified pill and empty state,+ /// and Req 6.3's value that no work carries — one walk, for the reason the+ /// main journey is one walk.+ func testTheStatusFiltersNarrowTheListAndNameTheirDimension() {+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")++ // Work status: the fixture's one work on hiatus.+ chooseOption("works-filter-work-status-hiatus", labelled: "On hiatus")+ assertListed(["Ashfall"], "A work-status filter keeps the works carrying it")+ waitFor(pill("Work: On hiatus"), "Req 6.2: the pill names the dimension it filtered")+ XCTAssertEqual(+ optionsMenu.label, "Sort and filter works, filters active",+ "…and the menu icon fills as it does for the other three dimensions")++ // Reading status, on its own: Clear first, so the two are not ANDed into+ // a miss neither dimension is responsible for.+ app.anyElement("works-filter-clear").tap()+ assertListed(newestOrder, "Clear removes the status filter as it does any other")++ chooseOption("works-filter-reading-status-abandoned", labelled: "Abandoned")+ assertListed(["Marrow Lane"], "A reading-status filter keeps the works carrying it")+ waitFor(pill("Reading: Abandoned"), "…under its own qualified pill")++ // Req 6.3: the two dimensions are not faceted and their vocabularies are+ // closed, so a pair no work carries is reachable — and the empty state+ // names both dimensions rather than reading "Finished, Abandoned".+ chooseOption("works-filter-work-status-finished", labelled: "Finished")+ waitFor(+ app.anyElement("works-filter-empty"),+ "A combination no work carries is reachable and explains itself")+ let explanation = app.staticTexts.matching(+ NSPredicate(format: "label BEGINSWITH %@", "No works match")).firstMatch+ waitFor(explanation, "The empty state names what is narrowing the list")+ XCTAssertTrue(+ explanation.label.contains("Work: Finished")+ && explanation.label.contains("Reading: Abandoned"),+ "Req 6.2: both dimensions are named — was \(explanation.label)")++ // Q23's whole reason: both vocabularies have a value called "Finished",+ // and with the pair the fixture does carry, both pills are on screen at+ // once and have to be tellable apart.+ chooseOption("works-filter-reading-status-finished", labelled: "Finished")+ assertListed(["Zephyr Court"], "The pair one work does carry lists it")+ waitFor(pill("Work: Finished"), "The work side's Finished names itself")+ waitFor(pill("Reading: Finished"), "…and the reading side's names itself too")+ }++ /// Req 5.1, the one thing a unit test cannot see (Q61): **where** the glyphs+ /// are drawn and what they cost the row.+ ///+ /// Zephyr Court wears both marks beside a type tag; Quill Harbour wears+ /// neither and is the row the height is measured against. Frames are read+ /// after the list has settled on the opening sort, so both rows are laid out+ /// in the same pass.+ ///+ /// The "where" is a default-size fact and stays here; the height comparison+ /// is made twice, because at the default size it cannot fail — see+ /// `assertGlyphsCostNoHeightAtLargestDynamicType`, which relaunches at+ /// `accessibility5` and repeats it there.+ func testTheRowGlyphsFollowTheTypeTagAndCostTheRowNoHeight() throws {+ launch()+ waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+ assertListed(newestOrder, "The list has settled before any frame is read")++ let marked = waitFor(row(titled: "Zephyr Court"), "The doubly marked row is listed")+ let unmarked = waitFor(row(titled: "Quill Harbour"), "…as is the unmarked one")+ scrollUntilPresent(unmarked, in: app, "Both rows are on screen together")++ let typeTag = try XCTUnwrap(+ element("work-type-tag", inside: marked), "The marked row wears its type tag")+ let workGlyph = try XCTUnwrap(+ element("work-status-glyph", inside: marked), "…and the finished-work glyph")+ let readingGlyph = try XCTUnwrap(+ element("reading-status-glyph", inside: marked), "…and the finished-reading glyph")++ // "After the type tag", read as the layout fact it is: the work glyph+ // starts where the tag ends, and the reading glyph after that.+ XCTAssertGreaterThanOrEqual(+ workGlyph.frame.minX, typeTag.frame.maxX - 1,+ "Req 5.1: the work-status glyph follows the type tag")+ XCTAssertGreaterThanOrEqual(+ readingGlyph.frame.minX, workGlyph.frame.maxX - 1,+ "Req 5.1: the reading-status glyph follows it")+ XCTAssertTrue(+ marked.frame.contains(workGlyph.frame) && marked.frame.contains(readingGlyph.frame),+ "Both glyphs are drawn inside the row, not beside it")++ // "No taller than the type tag", and the row no taller for them.+ XCTAssertLessThanOrEqual(+ workGlyph.frame.height, typeTag.frame.height + 1,+ "Req 5.1: a glyph is no taller than the tag beside it")+ XCTAssertLessThanOrEqual(+ readingGlyph.frame.height, typeTag.frame.height + 1,+ "…either of them")+ XCTAssertLessThanOrEqual(+ marked.frame.height, unmarked.frame.height + 1,+ "Req 5.1: a row wearing two glyphs is no taller than one wearing none")++ assertGlyphsCostNoHeightAtLargestDynamicType()+ }++ /// The height half of Req 5.1, at the size where it can fail.+ ///+ /// **At the default size the comparison above cannot discriminate.** Every+ /// row carries the entry-count pill whatever else it wears, and the row+ /// floors at `AsterismLayout.minHitTarget`; between them the height is+ /// settled before a glyph is drawn, so a glyph twice the tag's height would+ /// still leave the two rows equal. At `accessibility5` the content is what+ /// sets the height, so a glyph that does not follow the pill's text font+ /// shows up as a taller row — which is the failure the requirement is about.+ ///+ /// Read under **A to Z**, not the opening sort: the two rows have to be laid+ /// out in the same pass, and at this size only adjacent ones fit on screen+ /// together. Newest first puts Zephyr Court first and Quill Harbour last, in+ /// its own trailing no-notes section; A to Z puts them side by side.+ private func assertGlyphsCostNoHeightAtLargestDynamicType() {+ terminateAndWaitForExit(app)+ launch(textSize: "UICTContentSizeCategoryAccessibilityXXXL")+ waitFor(+ app.collectionViews["recent-list"], "The library opens at largest Dynamic Type",+ timeout: 60)+ selectTab(.works, in: app)+ waitFor(app.collectionViews["works-list"], "Works lists the seeded library again")+ chooseOption("works-sort-aToZ", labelled: "A to Z")++ let marked = row(titled: "Zephyr Court")+ scrollUntilPresent(+ marked, in: app, "The doubly marked row is reachable at largest Dynamic Type")+ let unmarked = row(titled: "Quill Harbour")+ XCTAssertTrue(+ unmarked.exists,+ "…with the unmarked control row laid out in the same pass, so the two "+ + "heights are comparable")+ XCTAssertLessThanOrEqual(+ marked.frame.height, unmarked.frame.height + 1,+ "Req 5.1: at largest Dynamic Type, where the row height follows its content, "+ + "two glyphs still cost the row nothing")+ }++ // MARK: - Row helpers++ /// One row, by the work it opens. `work-row-` identifiers carry a uuid the+ /// test cannot know, and the row's own label is the only place the title is.+ private func row(titled title: String) -> XCUIElement {+ workRows.matching(NSPredicate(format: "label BEGINSWITH %@", "Open Work \(title) "))+ .firstMatch+ }++ /// One active-filter pill, by the words on it. Every pill carries the same+ /// identifier — one per value, which is what the row is — so the label is+ /// what tells two of them apart.+ private func pill(_ text: String) -> XCUIElement {+ app.descendants(matching: .any).matching(+ NSPredicate(+ format: "identifier == %@ AND label == %@", "works-filter-pills", text)+ ).firstMatch+ }++ /// An element inside a given row, found by frame containment.+ ///+ /// A row's children all share their identifiers with the other rows'+ /// (`work-type-tag` is on every typed row), so the query has to be narrowed+ /// by geometry rather than by index — the index depends on the sort.+ private func element(_ identifier: String, inside row: XCUIElement) -> XCUIElement? {+ let matches = app.descendants(matching: .any).matching(identifier: identifier)+ let rowFrame = row.frame+ for index in 0..<matches.count {+ let candidate = matches.element(boundBy: index)+ if rowFrame.contains(candidate.frame) { return candidate }+ }+ return nil+ } }
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 46236f1..f77ceb3 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,188 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- **Work and reading status is documented and verified+ (work-and-reading-status, phase "Documentation and verification",+ T-2306 — spec complete).** The style guide's §7 and §8 carry the+ meta-line items, the row glyphs, the abandoned knock-down, the two+ captions and the four symbols; `docs/asterism-design.md` §5.2 and §6+ describe the two statuses and the finished-reading rule; the+ `schema-migration` and `testing` agent notes are rewritten for V10+ live over a frozen V9, markers `"9"` and `"10"`, the+ `V9RecordedStore` pair and `MarkerGenerationTenTests`, with their+ Phase 1 banners gone; `rule-wire-format` names `BackupV9*`; the+ design doc records the landed dialog shape (Q62) and the popover+ presentation (Q65). `specs/work-and-reading-status/verification-run.md`+ records the full run: `make test-core` (one known-flake rerun),+ `make test-quick` with the Mac build, `make test-ui` (its only+ failures the pre-existing `M4ScaleRecentPerformanceUITests` trio),+ `make test-ui-ipad`, and `make test-performance-m4` at eight known+ issues with no ceiling left — the capture-projection arms moved+ 0.160–0.170 s to 0.169–0.176 s for the three wider `Work` columns,+ which `CLAUDE.md`'s performance paragraph now states. No device+ target was run and the Mac app was never launched; the owner's+ device checks and the `prerequisites.md` steps are listed in the+ record, and the specs overview marks the feature Done.++- **The works-options fixture carries statuses and the UI journeys+ read them (work-and-reading-status, phase "UI fixture and journeys",+ T-2306).** `seedWorksOptionsWork` seeds Marrow Lane abandoned with a+ verdict, Ashfall on hiatus, Zephyr Court finished and finished with a+ verdict, and Quill Harbour on the defaults (Q58 keeps the removed-type+ pill and the abandoned row on different works). `WorksListOptionsUITests`+ proves the abandoned row sinks under a query and a filter and says so,+ both status filters narrow the list with dimension-qualified pills,+ and the glyphs follow the type tag at no row height cost, measured at+ default type and at accessibility5 (Q68). A new+ `WorkDetailStatusUITests` walks the finished-reading dialog, marks the+ work finished, saves and reads both meta-line items and the verdict,+ cancels, steps a finished reading back with no dialog, and reads a+ merge destination's status clause. The XXXL accessibility journey+ covers a status filter, both capsules, the verdict field, the dialog+ and a row wearing both glyphs; the iPad wide-layout suite reads both+ meta-line items. Two findings: on iOS 26 the dialog is an anchored+ popover at every size and its declared Cancel is never drawn on the+ phone, so the third action is the dismiss region (Q65, proposed, the+ requirement wording is the owner's call), and the shared+ `declineConfirmationDialog` helper was tapping inside a button at+ accessibility5, a false green now fixed and swept over every caller+ (Q66). Req 5.2's dimming is the owner's device check (Q67). The+ three `M4ScaleRecentPerformanceUITests` failures in `make test-ui`+ are pre-existing on this machine, as `ipad-and-mac-layouts` recorded.++- **The work detail screen edits both statuses and enforces the+ finished-reading rule (work-and-reading-status, phase "Work detail and+ the finished-reading rule", T-2306).** `WorkDetailModel` carries a+ work-status draft, a reading-status draft and a verdict draft, seeded+ on load, compared in `hasUnsavedChanges` (the verdict untrimmed, like+ the title, Q63), restored with the other drafts, and sent by `save`+ in the draft and in the nil-snapshot fallback basis (Q40, Q47). The+ transition table lands as designed: reading → finished over an+ unfinished work raises the "Mark as finished?" dialog on the picker+ (Req 3.1, 3.2), moving the work off finished auto-reverts a finished+ reading to reading (Q7) and returning it before commit restores the+ reverted value (Q39), abandoned is never prompted (Q5), a re-tap is a+ no-op, and `commitEditing()` runs the same gate first, only when the+ pair changed this session, so a cancel leaves every draft intact and+ a stored violating pair with an unrelated edit writes back unchanged+ (Q20, Q37). The dialog is a `presenting:` value on the delete+ prompt's shape — `resolveFinishedReadingPrompt(_:choosing:)` carries+ the prompt and Cancel is the sync `cancelFinishedReadingPrompt()`+ (Q62), with the message wording recorded in Q64. Edit mode gains two+ captioned segmented capsules and a verdict field between Type and+ Tags (Q26), the verdict field speaking its caption for VoiceOver+ (Req 10.1); the meta line shows the two statuses as items from the+ presentation table and a `Verdict` paragraph sits between the tag row+ and the notes while the reading is finished or abandoned.++- **The Works list shows, dims, sorts and filters by status+ (work-and-reading-status, phase "Works list and filters", T-2306).**+ A new `WorkStatusPresentation` / `ReadingStatusPresentation` table+ beside `WorkTypePresentation` carries every name, symbol, hue,+ qualified accessibility label and verdict prompt (Q36), and the+ duplicate-resolution sheet's interim words now delegate to it (Q54).+ `WorkRow` draws a glyph after the type tag for each non-default value+ (`flag.checkered` / `pause.circle` in violet, `checkmark` /+ `book.closed` in cyan, Q27), knocks an abandoned row down to the+ style guide's opacity with a secondary-text title (Req 5.2; a removed+ type's pill double-dims, accepted per Q58), and `WorksSort.apply`+ ends with an abandoned-last partition that keeps every sort's order+ inside each half (Q31). `WorksFilter` gains a work-status and a+ reading-status dimension with nil defaults and dimension-qualified+ pills (Q23, Q30); `pruned` leaves the closed vocabularies alone. The+ row's open label and the merge picker's destination label append+ "Work: …" and "Reading: …" clauses, the merge label placing them+ before the refusal with a full stop between (Q59). Req 5.1's glyph+ placement is carried by a task 18 journey (Q61).++- **The backup archive moves to 9 over 10 and carries the three status+ fields (work-and-reading-status, phase "Backup archive 9 over 10",+ T-2306).** The `BackupV8*` set is renamed `BackupV9*` as a tracked+ rename following the `rule-citation-by-uuid` precedent, with+ `formatVersion 9` over `schemaVersion 10`, the `multi-site` gate and+ the `v9` staged filename unchanged. `BackupV9Work` carries+ `workStatus`, `readingStatus` and `verdict` as required wire fields+ (Q34), pinned by a test that a 9/10 record omitting them fails to+ decode; export projects the carrier's values and refuses an unknown or+ empty status raw by name beside the `titleProvenance` refusal+ (Req 8.3, Q57); import writes all three to every row of a non-torn+ group and the verdict verbatim (Q55). An 8/9 archive is refused by+ version naming both pairs (Req 8.2, Q17) and the 8/9 importer is+ gone, which closes Req 2.6's status-less-record arm by construction+ (Q56). `backup-8-9-golden.json` is deleted and `backup-9-10-golden.json`+ recorded from a golden library that now holds a work on hiatus,+ abandoned, with a verdict. The Q46 transient state — a schema-10 store+ stamping an 8/9 archive — is closed.++- **Duplicate resolution and merge carry the three status fields+ (work-and-reading-status, phase "Duplicate resolution and merge",+ T-2306).** `DuplicateResolutionField` and `WorkVariantChoice` gain+ `workStatus`, `readingStatus` and `verdict`; `differingWorkFields`+ names them, so a status-only tear now raises a review card that says+ which field differs, and a resolution writes the carrier's three to+ every surviving row. `WorkVariantSide` carries them as required+ parameters (Q52) and the shared `WorkVariantUnion.fold` keeps the+ target's values, lists a source status as discarded only when it is+ non-default and differs (Q38), and records a differing non-blank source+ verdict as a `Verdict:` line in the audit block on both the merge and+ the resolution path (Q41), escaped like the merged-from header so a+ multi-line verdict cannot forge a block boundary (Q53).+ `WorkMergeField` gains six cases and `recordedInNotes`, and the merge+ preview's "recorded in merged notes" caption and audit-block label are+ now truthful per field. The duplicate-resolution sheet shows the+ work status, the reading status and a `Verdict:` line, with its own+ words until task 15's presentation table lands (Q54). The outcome's+ three target values are carried but unrendered (Q51).++- **The three status fields ride the authored-content write chain+ (work-and-reading-status, phase "Authored-content write chain",+ T-2306).** `WorkAuthoredContent` carries `workStatus`, `readingStatus`+ and `verdict` with three `orderComponents` slots, `isBare` treats the+ defaults and an empty verdict as bare, and `authoredContent(of:)` stays+ the single producer. `WorkSnapshot` and both `snapshot` arms (the split+ arm reads the carrier) surface them; `WorkMetadataDraft` takes them as+ required parameters (Q40) while `WorkEditBasis` defaults them (Q47),+ with `init(work:)` and `matches` covering all three and the detail+ model's nil-snapshot fallback basis stating them explicitly.+ `updateWork` writes all three to every row of the group and trims the+ verdict there and nowhere else (Q33); an all-whitespace verdict leaves+ the work bare. `DuplicateReconciler.apply` gains the `genreTags`-shaped+ non-default guard so a carrier on the defaults never overwrites a+ sibling's status. A captured and a manually created work read+ ongoing / reading / `""` on both doors. Until task 17 the detail model+ sends the loaded values back on save, so a save from a stale screen+ writes them over another device's change (Q49, last write wins as for+ every field); a status-only tear raises a review card that lists no+ differing field until task 11 lands. Req 7.2's "only the default+ statuses" means a bare row (Q48) and the backup-import creation-defaults+ arm moved to task 13 (Q50).++- **Schema V10, the two status enums and marker generation 10+ (work-and-reading-status, phase "Schema V10 and bootstrap", T-2306).**+ `Work` gains three defaulted columns — `workStatusRaw` (`"ongoing"`),+ `readingStatusRaw` (`"reading"`) and `verdict` (`""`) — read through two+ `ToleratedEnum` accessors so an unknown or empty raw reads as the default,+ with `WorkStatus` (ongoing, finished, hiatus) and `ReadingStatus`+ (reading, finished, abandoned; `isDone`) in `DomainEnums`.+ `AsterismSchemaV9` is now the frozen snapshot, verified column for column+ against the pre-feature models; `AsterismSchemaV10` carries the ten+ models and `AsterismV10MigrationPlan` is `[V9, V10]` with one lightweight+ stage (Q18). `AsterismSchemaV8` and the `V8RecordedStore*` pair are+ deleted (Q43) and a `V9RecordedStoreFixture`/`V9RecordedStoreTests` pair+ takes their place. The readiness markers move to `"9"` lagging and+ `"10"` published — the first two-character marker; no read path assumed+ one — and an `"8"` store is refused naming the digit. The tests'+ unrecognised-marker literal moved from `"10"` to `"99"` (Q28), the+ marker suites moved one generation (`MarkerGenerationTenTests`), three+ suites the task list did not name moved with them (Q44), and+ `library-graph-baseline.txt` is at format 7 (Q35). The design review+ retired the orphaned `WorkType` enum (Q42) and trimmed a version pin+ that contradicted its own comment (Q45). **Not releasable on its own**:+ the archive still stamps `8/9` over a schema-10 store until the+ backup phase renames the set (Q46). The owner-side `Development` run+ that publishes the three fields to the dev CloudKit container is now+ due (`prerequisites.md`).+ - **Pre-push review fixes for background-export (T-2052).** An activation after a pass had deferred arrivals ran the diagnosis and snapshot refresh twice, and an ordinary arrival never cleared the
diff --git a/CLAUDE.md b/CLAUDE.mdindex ad318ed..32fee26 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -58,7 +58,7 @@ invocations where a target exists. - `make test-quick` — unit-test bundle only (simulator), preceded by `build-mac`: a macOS compile failure fails it (Req 9.1). The Mac build is never installed or launched. `SKIP_MAC=1` drops that dependency loudly and owes a clean `make build-mac` before the push. - `make test` / `make test-ui` — full suites (simulator, iPhone); they skip the iPad-only suites by name - `make test-ui-ipad` — the wide-layout and wide-layout-accessibility suites on `IPAD_SIMULATOR` (simulator, safe)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, and 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, plus a ~190 s release build): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **eight** since `drop-superseded-columns` (four before `multi-site-works`, nine after it). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s, the one on a path the reader waits on). The eighth is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table. Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/drop-superseded-columns/verification-run.md` for the current numbers, `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, and 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, plus a ~190 s release build): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **eight** since `drop-superseded-columns` (four before `multi-site-works`, nine after it). Four are long-standing: Req 10.1's settling pass and Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → **0.169–0.176 s at V10**, the one on a path the reader waits on; the three new `Work` columns and the wider `orderComponents` cost them 3–6%, still well inside a 250 ms ceiling). The eighth is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10). Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-and-reading-status/verification-run.md` §4 for the current numbers, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above**
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swiftindex eabfaa6..4062b59 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift@@ -25,7 +25,7 @@ import SwiftData /// archive's own. internal enum ArchiveRecordBuilders { - static func makeSite(_ record: BackupV8Site) -> Site {+ static func makeSite(_ record: BackupV9Site) -> Site { let site = Site(hostname: record.hostname, displayName: record.displayName) site.modeRaw = record.mode.rawValue site.junkSuffixRule = record.junkSuffixRule@@ -33,7 +33,7 @@ internal enum ArchiveRecordBuilders { } static func makeTitlePattern(- _ record: BackupV8TitlePattern, site: Site?+ _ record: BackupV9TitlePattern, site: Site? ) throws -> TitlePattern { return try TitlePattern( id: record.id,@@ -48,7 +48,7 @@ internal enum ArchiveRecordBuilders { } static func makeURLRule(- _ record: BackupV8URLRule, site: Site?+ _ record: BackupV9URLRule, site: Site? ) throws -> URLRulePattern { try URLRulePattern( id: record.id,@@ -64,7 +64,7 @@ internal enum ArchiveRecordBuilders { /// The wire timestamps are what an import-created row carries on both fields /// (Q33), and an unrecognised state coerces to `.active` rather than /// refusing — a type row from a later build's wider set is legal data.- static func makeWorkType(_ record: BackupV8WorkType) -> WorkTypeEntity {+ static func makeWorkType(_ record: BackupV9WorkType) -> WorkTypeEntity { makeWorkType( id: record.id, name: record.name, state: ToleratedEnum.read(record.stateRaw, default: .active),@@ -91,7 +91,7 @@ internal enum ArchiveRecordBuilders { /// Work has no site column to name: its site presence is its /// `WorkSiteMembership`, which `makeMembership` below builds from the /// archive's own records.- static func makeWork(_ record: BackupV8Work) -> Work {+ static func makeWork(_ record: BackupV9Work) -> Work { let work = Work( id: record.id, displayTitle: record.displayTitle,@@ -105,7 +105,7 @@ internal enum ArchiveRecordBuilders { /// carries it. `workID` travels whether or not the Work is there (Q37), so an /// orphan re-attaches when its Work arrives (Req 8.3, 9.5). static func makeMembership(- _ record: BackupV8Membership, work: Work?, site: Site?+ _ record: BackupV9Membership, work: Work?, site: Site? ) -> WorkSiteMembership { WorkSiteMembership( id: record.id,@@ -123,13 +123,13 @@ internal enum ArchiveRecordBuilders { /// One dismissed pair. The record's ids are already in the canonical sorted /// order — `BackupImportPayload` normalises them at the door — so nothing /// here re-sorts and then disagrees about which end is which.- static func makeDistinctPair(_ record: BackupV8DistinctPair) -> WorkDistinctPair {+ static func makeDistinctPair(_ record: BackupV9DistinctPair) -> WorkDistinctPair { WorkDistinctPair( id: record.id, lowerWorkID: record.lowerWorkID, higherWorkID: record.higherWorkID, recordedAt: record.recordedAt) } - static func makeEntry(_ record: BackupV8Entry) -> Entry {+ static func makeEntry(_ record: BackupV9Entry) -> Entry { let entry = Entry( id: record.id, captureTitle: record.captureTitle,@@ -144,7 +144,7 @@ internal enum ArchiveRecordBuilders { return entry } - static func makeCharacter(_ record: BackupV8Character) -> CharacterRecord {+ static func makeCharacter(_ record: BackupV9Character) -> CharacterRecord { let character = CharacterRecord( id: record.id, name: record.name, nameKey: record.nameKey, aliases: record.aliases, note: record.note, facts: record.facts,@@ -156,7 +156,7 @@ internal enum ArchiveRecordBuilders { /// The raw columns travel verbatim, so a value written by a later build's /// wider set survives the round trip rather than being coerced to this /// build's default.- static func makeSuppression(_ record: BackupV8Suppression) -> CharacterSuppression {+ static func makeSuppression(_ record: BackupV9Suppression) -> CharacterSuppression { let row = CharacterSuppression( id: record.id, kind: record.kind, nameKey: record.nameKey, source: record.source, evidence: record.evidence, status: record.status,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swiftindex 31495c8..e6d41e9 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift@@ -12,13 +12,14 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable { case m3 = "m3" case m4 = "m4" /// V8's gate (`multi-site-works` Q29): a Work holds site memberships,- /// and the archive it writes is format 8 over schema 9. Nothing about a+ /// and the archive it writes is format 9 over schema 10. Nothing about a /// *rule form* changes with it — every `supports…` answer below is m4's /// — so the case exists to name the store shape and the rule-form set,- /// which is what `BackupV8Codec` stamps. The literal did not move when- /// the archive did: 8/9 changes neither of those two things (Q19), and- /// the generation is named by its format and schema numbers, which are- /// what the importer gates on.+ /// which is what `BackupV9Codec` stamps. The literal has not moved with+ /// either archive generation since: neither 8/9 nor 9/10 changes those+ /// two things (`rule-citation-by-uuid` Q19), and the generation is named+ /// by its format and schema numbers, which are what the importer gates+ /// on. case multiSite = "multi-site" } @@ -31,7 +32,7 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable { public static let multiSite = AsterismCapabilities(gate: .multiSite) /// The current runtime gate is `.multiSite` (`multi-site-works` Q29).- /// `BackupV8Codec` stamps the literal `"multi-site"` rather than reading+ /// `BackupV9Codec` stamps the literal `"multi-site"` rather than reading /// this value, so the archive's gate is independent of the runtime's. /// Earlier gates stay available because the schema and teaching suites still /// exercise them — `CapabilityGatingTests`, `PhraseParsingTests`,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV10.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV10.swiftnew file mode 100644index 0000000..3f610e9--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV10.swift@@ -0,0 +1,67 @@+import Foundation+import SwiftData++/// The runtime schema. Its body is `Models.swift`, which opens+/// `extension AsterismSchemaV10`.+///+/// V10 is V9 **plus** three defaulted `Work` columns — `workStatusRaw`,+/// `readingStatusRaw` and `verdict` — the reader-entered statuses and the+/// verdict text `work-and-reading-status` adds. Nothing else moves: no table is+/// added or removed, no column changes type, and no relationship changes shape.+///+/// **This is the first version in this project to add a non-optional scalar to+/// an existing table under a bare lightweight stage.** The property initialiser+/// is what SwiftData turns into the Core Data attribute default, and that+/// default is what fills the three columns on every existing row inside+/// `ModelContainer.init` (Req 9.1). `V9RecordedStoreTests` therefore asserts the+/// **raw columns** after conversion rather than the accessors: an accessor+/// reading `ToleratedEnum.read(_, default:)` would answer `.ongoing` even if the+/// default never landed.+///+/// The entity *list* is unchanged: V10 adds no table and removes none.+public enum AsterismSchemaV10: VersionedSchema {+ public static let versionIdentifier = Schema.Version(10, 0, 0)++ public static var models: [any PersistentModel.Type] {+ [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,+ WorkTypeEntity.self, Character.self, CharacterSuppression.self,+ WorkSiteMembership.self, WorkDistinctPair.self]+ }+}++/// The migration plan: `[V9, V10]`, one lightweight stage.+///+/// **The V8 → V9 stage is retired** (Q18 of `work-and-reading-status`). Every+/// device is confirmed at marker `"9"` on 2026-09-04, which is+/// `retire-migration-chain` Decision 6's population precondition, so+/// `AsterismSchemaV8` went with the stage that named it. A store older than V9+/// fails closed — `NSCocoaErrorDomain` 134504, "Cannot use staged migration with+/// an unknown model version" — and the recovery is the backup archive, which is+/// what `V4RecordedStoreTests` pins.+///+/// The single stage is `.lightweight`, and it purely **adds**. There is no data+/// pass behind it: the three columns are defaulted, so the conversion is the+/// attribute defaults being written, and what certifies it is the converted+/// store validating (`BootstrapState.markerLagging`'s arm). `.custom` is not an+/// option here for the reason it never is: a custom stage would also run inside+/// the share extension, which must never migrate, and the extension is kept out+/// by the marker instead.+///+/// One consequence worth stating, because it is new at V10: the live stored+/// shape is no longer a **subset** of the frozen one. Until now every stage+/// either added tables that the frozen snapshot simply lacked or removed columns+/// the live classes no longer declared, and a stale snapshot registration in+/// SwiftData's global entity registry could at worst cost a column that would+/// not save. An adding version loses that, so `V9RecordedStoreFixture`'s+/// create-seed-save-**release** ordering is the only thing holding the registry+/// coherent, together with `make test-core`'s `--no-parallel`+/// (`docs/agent-notes/schema-migration.md`).+public enum AsterismV10MigrationPlan: SchemaMigrationPlan {+ public static var schemas: [any VersionedSchema.Type] {+ [AsterismSchemaV9.self, AsterismSchemaV10.self]+ }++ public static var stages: [MigrationStage] {+ [.lightweight(fromVersion: AsterismSchemaV9.self, toVersion: AsterismSchemaV10.self)]+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swiftdeleted file mode 100644index fdf9fc8..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift+++ /dev/null@@ -1,285 +0,0 @@-import Foundation-import SwiftData--/// The frozen `multi-site-works` schema — the shape every installed library was-/// written by before `drop-superseded-columns`, and the `from` version of the-/// V8 → V9 lightweight stage.-///-/// V8 is V7 plus the `WorkSiteMembership` and `WorkDistinctPair` tables and the-/// two Codable blob columns `Entry.citationsData` and-/// `TitlePattern.definitionData`. **It only added**: the columns those three-/// superseded stayed in the stored shape, because the lightweight stage runs-/// inside `ModelContainer.init` and a stage that dropped them would have-/// destroyed the source before `V8PopulationPass` could copy it. This snapshot-/// is what that shape looked like — `Work`'s six site/identity/URL columns and-/// `typeRaw`, `Entry.identityKeyVersion` and its seventeen citation columns,-/// `TitlePattern`'s ten definition columns, `Site.urlIdentityRule`, and the-/// `Work.site` ↔ `Site.works` inverse pair — all of which V9 drops.-///-/// V8 is frozen for the same reason V5, V6 and V7 were: *any* edit to its body-/// makes a V8-recorded store refuse to open with `NSCocoaErrorDomain` 134504,-/// "Cannot use staged migration with an unknown model version". The live classes-/// therefore moved to `AsterismSchemaV9`, and this declaration exists only to-/// give `AsterismV9MigrationPlan` a `from` version — and to let-/// `V8RecordedStoreFixture` seed a genuinely 8.0.0-recorded store in-process.-///-/// The classes are nested so they can carry the same SwiftData entity names-/// ("Entry", "Site", …) as the live V9 classes without a top-level collision:-/// the only top-level references are typealiases, and two *top-level* `@Model`s-/// sharing an entity name crash `ModelContext`-/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V8-shaped object at-/// runtime, so these carry stored columns only — no accessors, no business-/// logic.-///-/// # These snapshots are frozen *by reference*, not only by file-///-/// The nesting freezes the class bodies; it does **not** freeze anything a body-/// *names*. Editing one of those changes the stored shape of this frozen schema-/// silently — and that is exactly what makes a recorded store refuse to open-/// (134504). Two families of referent, both live and both shared with V9:-///-/// * **The stored value types.** `SegmentRangeSpec`, `SegmentPositionSpec`,-/// `URLIdentityRule` and `JunkSuffixRule` are top-level types in-/// `ValueObjects.swift`. Their stored properties are this schema's stored-/// properties.-/// * **Every enum whose raw value is baked into a default.** A default is part-/// of the shape, so `WorkType.other.rawValue`, `PatternForm.segment.rawValue`,-/// `FieldProvenanceKind.none.rawValue`, `WorkURLIdentityState.none.rawValue`,-/// `SiteMode.untaught.rawValue`, `CaptureTitleSource.manual.rawValue`,-/// `EntryIdentityBasis.conservative.rawValue`, `TitleProvenance.manual`,-/// `URLRuleOrigin.readerTaught`, `WorkTypeState.active`,-/// `CharacterSuppressionKind.candidate` and-/// `CharacterSuppressionStatus.active` are all frozen *spellings* here, not-/// merely frozen references. Renaming a case, or reordering one whose raw-/// value is derived rather than written out, edits this file without touching-/// it.-///-/// `WorkType` is the one to watch: V9 dropped `Work.typeRaw`, so this-/// snapshot's default is the enum's **only** live referent. Nothing else in-/// the package reads it, which makes it look unused to every tool and to-/// every reader who has not read this paragraph. It is not — it is a frozen-/// persisted default, and it may not be edited or deleted while a store-/// recorded at 8.0.0 can still exist.-public enum AsterismSchemaV8: VersionedSchema {- public static let versionIdentifier = Schema.Version(8, 0, 0)-- public static var models: [any PersistentModel.Type] {- [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,- WorkTypeEntity.self, Character.self, CharacterSuppression.self,- WorkSiteMembership.self, WorkDistinctPair.self]- }-}--extension AsterismSchemaV8 {- @Model- public final class Entry {- public var id: UUID = UUID()- public var captureTitle: String = ""- public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue- public var rawURLString: String = ""- public var canonicalURLString: String?- public var hostname: String = ""- public var site: Site?- public var entryIdentityKey: String = ""- public var identityKeyVersion: Int = 1- public var conservativeIdentityKey: String = ""- public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue- public var identityURLRuleID: UUID?- public var identityURLRuleVersion: Int?- public var identityNameTitleRuleID: UUID?- public var identityNameTitleRuleVersion: Int?- public var urlWorkIdentity: String?- public var urlWorkRuleID: UUID?- public var urlWorkRuleVersion: Int?- public var chapterSequence: String?- public var chapterSequenceRuleID: UUID?- public var chapterSequenceRuleVersion: Int?- public var chapterTitle: String?- public var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue- public var chapterPatternID: UUID?- public var chapterPatternVersion: Int?- public var note: String = ""- public var ratingRaw: String?- public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)- public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)- public var work: Work?- public var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue- public var workPatternID: UUID?- public var workPatternVersion: Int?- public var workURLRuleID: UUID?- public var workURLRuleVersion: Int?- public var workURLAssignmentKindRaw: String?- public var intentionallyUnattached: Bool = false- public var characterExtractionFingerprint: String?- public var citationsData: Data?-- public init() {}- }-- @Model- public final class Work {- public var id: UUID = UUID()- public var displayTitle: String = ""- public var lastParsedTitle: String?- public var siteHostname: String = ""- public var site: Site?- public var urlIdentity: String?- public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue- public var urlIdentityRuleID: UUID?- public var urlIdentityRuleVersion: Int?- public var workURLString: String?- public var genericNotes: String = ""- public var typeRaw: String = WorkType.other.rawValue- public var workTypeID: UUID?- public var genreTags: [String] = []- public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)- public var genericNotesExtractionFingerprint: String?- @Relationship(deleteRule: .nullify, inverse: \Entry.work)- public var entries: [Entry]?- @Relationship(deleteRule: .nullify, inverse: \Character.work)- public var characters: [Character]?- @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)- public var characterSuppressions: [CharacterSuppression]?- @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.work)- public var siteMemberships: [WorkSiteMembership]? = []-- public init() {}- }-- @Model- public final class Site {- public var hostname: String = ""- public var displayName: String = ""- public var modeRaw: String = SiteMode.untaught.rawValue- @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)- public var patterns: [TitlePattern]?- @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)- public var urlRules: [URLRulePattern]?- /// Inverse of `Entry.site`, present only because CloudKit requires every- /// relationship to have one. Internal for the same reason the live class- /// keeps it internal (Q17): traversing it faults every Entry for a- /// hostname.- @Relationship(deleteRule: .nullify, inverse: \Entry.site)- var entries: [Entry]?- /// Inverse of `Work.site`. Same reasoning as `entries`. **V9 drops both- /// halves of this pair**, which is the one relationship change the- /// V8 → V9 stage makes.- @Relationship(deleteRule: .nullify, inverse: \Work.site)- var works: [Work]?- /// Inverse of `WorkSiteMembership.site`. Same reasoning again.- @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site)- var workMemberships: [WorkSiteMembership]?- public var urlIdentityRule: URLIdentityRule?- public var junkSuffixRule: JunkSuffixRule?-- public init() {}- }-- @Model- public final class TitlePattern {- public var id: UUID = UUID()- public var version: Int = 1- public var isActive: Bool = false- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var formRaw: String = PatternForm.segment.rawValue- public var segmentWorkAnchor: SegmentRangeSpec?- public var segmentIgnoredAnchors: [SegmentPositionSpec]?- public var phrasePrefix: String?- public var phraseSeparator: String?- public var phraseSuffix: String?- public var fieldOrderRaw: String?- public var trimPrefix: String?- public var trimSuffix: String?- public var chapterless: Bool = false- public var definitionData: Data?- public var site: Site?-- public init() {}- }-- @Model- public final class URLRulePattern {- public var id: UUID = UUID()- public var version: Int = 1- public var isCurrent: Bool = false- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var originRaw: String = URLRuleOrigin.readerTaught.rawValue- public var definitionData: Data = Data()- public var site: Site?-- public init() {}- }-- @Model- public final class WorkTypeEntity {- public var id: UUID = UUID()- public var name: String = ""- public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var stateRaw: String = WorkTypeState.active.rawValue- public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)- public var canonicalID: UUID?- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-- @Model- public final class Character {- public var id: UUID = UUID()- public var name: String = ""- public var nameKey: String = ""- public var aliases: [String] = []- public var note: String = ""- public var factsData: Data?- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var modifiedAt: Date = Date(timeIntervalSince1970: 0)- public var work: Work?-- public init() {}- }-- @Model- public final class CharacterSuppression {- public var id: UUID = UUID()- public var work: Work?- public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue- public var nameKey: String = ""- public var sourceKindRaw: String?- public var sourceEntryID: UUID?- public var evidence: String?- public var statusRaw: String = CharacterSuppressionStatus.active.rawValue- public var actionAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-- @Model- public final class WorkSiteMembership {- public var id: UUID = UUID()- public var hostname: String = ""- public var createdAt: Date = Date(timeIntervalSince1970: 0)- public var urlIdentity: String?- public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue- public var urlIdentityRuleID: UUID?- public var workURLString: String?- public var workID: UUID?- public var work: Work?- public var site: Site?-- public init() {}- }-- @Model- public final class WorkDistinctPair {- public var id: UUID = UUID()- public var lowerWorkID: UUID = UUID()- public var higherWorkID: UUID = UUID()- public var recordedAt: Date = Date(timeIntervalSince1970: 0)-- public init() {}- }-}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swiftindex af16be9..98b8f54 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift@@ -1,24 +1,59 @@ import Foundation import SwiftData -/// The runtime schema. Its body is `Models.swift`, which opens-/// `extension AsterismSchemaV9`.+/// The frozen `drop-superseded-columns` schema — the shape every installed+/// library was written by before `work-and-reading-status`, and the `from`+/// version of the V9 → V10 lightweight stage. ///-/// V9 is V8 **minus** everything V8 superseded but could not drop. The-/// `WorkSiteMembership` row is a Work's site presence, the `Entry.citationsData`-/// blob is its citations and `TitlePattern.definitionData` is a pattern's-/// definition surface — so `Work`'s six site/identity/URL columns and its-/// `typeRaw`, `Entry.identityKeyVersion` and its seventeen citation columns,-/// `TitlePattern`'s ten definition columns and `Site.urlIdentityRule` are gone,-/// along with the `Work.site` ↔ `Site.works` inverse pair.+/// V9 was V8 **minus** everything V8 superseded but could not drop: `Work`'s six+/// site/identity/URL columns and its `typeRaw`, `Entry.identityKeyVersion` and+/// its seventeen citation/provenance columns, `TitlePattern`'s ten definition+/// columns, `Site.urlIdentityRule`, and the `Work.site` ↔ `Site.works` inverse+/// pair. Nothing here names any of them; this is the narrowed shape they left+/// behind, and V10 adds three `Work` columns to it. ///-/// The drop is safe only because the population it undoes is finished: the-/// readiness marker read `"8"` on every device before this schema shipped, which-/// is `V8PopulationPass`'s completion certificate (decision_log.md Q2 of-/// `drop-superseded-columns`). The pass itself is deleted — it had nothing left-/// to convert from.+/// V9 is frozen for the same reason V5, V6, V7 and V8 were: *any* edit to its+/// body makes a V9-recorded store refuse to open with `NSCocoaErrorDomain`+/// 134504, "Cannot use staged migration with an unknown model version". The live+/// classes therefore moved to `AsterismSchemaV10`, and this declaration exists+/// only to give `AsterismV10MigrationPlan` a `from` version — and to let+/// `V9RecordedStoreFixture` seed a genuinely 9.0.0-recorded store in-process. ///-/// The entity *list* is unchanged: V9 adds no table and removes none.+/// The classes are nested so they can carry the same SwiftData entity names+/// ("Entry", "Site", …) as the live V10 classes without a top-level collision:+/// the only top-level references are typealiases, and two *top-level* `@Model`s+/// sharing an entity name crash `ModelContext`+/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V9-shaped object at+/// runtime, so these carry stored columns only — no accessors, no business+/// logic.+///+/// # These snapshots are frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze anything a body+/// *names*. Editing one of those changes the stored shape of this frozen schema+/// silently — and that is exactly what makes a recorded store refuse to open+/// (134504). Two families of referent, both live and both shared with V10:+///+/// * **The stored value types.** `JunkSuffixRule` is a top-level type in+/// `ValueObjects.swift`; its stored properties are this schema's stored+/// properties. (`URLIdentityRule`, `SegmentRangeSpec` and `SegmentPositionSpec`+/// were V8's, on columns V9 dropped, and are no longer named by any snapshot.)+/// * **Every enum whose raw value is baked into a default.** A default is part+/// of the shape, so `CaptureTitleSource.manual.rawValue`,+/// `EntryIdentityBasis.conservative.rawValue`, `TitleProvenance.manual`,+/// `SiteMode.untaught.rawValue`, `URLRuleOrigin.readerTaught`,+/// `WorkTypeState.active`, `CharacterSuppressionKind.candidate`,+/// `CharacterSuppressionStatus.active` and `WorkURLIdentityState.none` are all+/// frozen *spellings* here, not merely frozen references. Renaming a case, or+/// reordering one whose raw value is derived rather than written out, edits+/// this file without touching it.+///+/// V10 adds two more of these to the live `Work` —+/// `WorkStatus.ongoing.rawValue` and `ReadingStatus.reading.rawValue`, the+/// defaults the V9 → V10 stage fills every existing row with. They are not in+/// *this* snapshot, and they become frozen spellings the moment V10 is frozen+/// in its turn; `work-and-reading-status`'s design says so, so the next freeze+/// does not have to rediscover it. public enum AsterismSchemaV9: VersionedSchema { public static let versionIdentifier = Schema.Version(9, 0, 0) @@ -29,31 +64,174 @@ public enum AsterismSchemaV9: VersionedSchema { } } -/// The migration plan: `[V8, V9]`, one lightweight stage.-///-/// **Every stage below V8 is retired** (decision_log.md Q2 of-/// `drop-superseded-columns`). Every device is confirmed at marker `"8"`, which-/// is `retire-migration-chain` Decision 6's population precondition for each of-/// them, so `AsterismSchemaV5`, `AsterismSchemaV6` and `AsterismSchemaV7` went-/// with the stages that named them. A store older than V8 fails closed —-/// `NSCocoaErrorDomain` 134504, "Cannot use staged migration with an unknown-/// model version" — and the recovery is the backup archive, which is what-/// `V4RecordedStoreTests` pins.-///-/// The single stage is `.lightweight`. **This is the first stage that removes-/// anything**: 36 attributes and one inverse relationship pair. It runs inside-/// `ModelContainer.init`, which is why the columns had to be unwritten and every-/// reader moved onto the blob and the membership *before* this schema existed —-/// the conversion has no chance to read what it is about to destroy. `.custom`-/// is not an option: a custom stage would also run inside the share extension,-/// which must never migrate, and the extension is kept out by the marker instead-/// (Q3).-public enum AsterismV9MigrationPlan: SchemaMigrationPlan {- public static var schemas: [any VersionedSchema.Type] {- [AsterismSchemaV8.self, AsterismSchemaV9.self]- }-- public static var stages: [MigrationStage] {- [.lightweight(fromVersion: AsterismSchemaV8.self, toVersion: AsterismSchemaV9.self)]+extension AsterismSchemaV9 {+ @Model+ public final class Entry {+ public var id: UUID = UUID()+ public var captureTitle: String = ""+ public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue+ public var rawURLString: String = ""+ public var canonicalURLString: String?+ public var hostname: String = ""+ public var site: Site?+ public var entryIdentityKey: String = ""+ public var conservativeIdentityKey: String = ""+ public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue+ public var urlWorkIdentity: String?+ public var chapterSequence: String?+ public var chapterTitle: String?+ 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 intentionallyUnattached: Bool = false+ public var characterExtractionFingerprint: String?+ public var citationsData: Data?++ public init() {}+ }++ @Model+ public final class Work {+ public var id: UUID = UUID()+ public var displayTitle: String = ""+ public var lastParsedTitle: String?+ public var genericNotes: String = ""+ public var workTypeID: UUID?+ public var genreTags: [String] = []+ public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var genericNotesExtractionFingerprint: String?+ @Relationship(deleteRule: .nullify, inverse: \Entry.work)+ public var entries: [Entry]?+ @Relationship(deleteRule: .nullify, inverse: \Character.work)+ public var characters: [Character]?+ @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)+ public var characterSuppressions: [CharacterSuppression]?+ @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.work)+ public var siteMemberships: [WorkSiteMembership]? = []++ public init() {}+ }++ @Model+ public final class Site {+ public var hostname: String = ""+ public var displayName: String = ""+ public var modeRaw: String = SiteMode.untaught.rawValue+ @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)+ public var patterns: [TitlePattern]?+ @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)+ public var urlRules: [URLRulePattern]?+ /// Inverse of `Entry.site`, present only because CloudKit requires every+ /// relationship to have one. Internal for the same reason the live class+ /// keeps it internal (Q17): traversing it faults every Entry for a+ /// hostname.+ @Relationship(deleteRule: .nullify, inverse: \Entry.site)+ var entries: [Entry]?+ /// Inverse of `WorkSiteMembership.site`. Same reasoning again.+ @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site)+ var workMemberships: [WorkSiteMembership]?+ public var junkSuffixRule: JunkSuffixRule?++ public init() {}+ }++ @Model+ public final class TitlePattern {+ public var id: UUID = UUID()+ public var version: Int = 1+ public var isActive: Bool = false+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var definitionData: Data?+ public var site: Site?++ public init() {}+ }++ @Model+ public final class URLRulePattern {+ public var id: UUID = UUID()+ public var version: Int = 1+ public var isCurrent: Bool = false+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var originRaw: String = URLRuleOrigin.readerTaught.rawValue+ public var definitionData: Data = Data()+ public var site: Site?++ public init() {}+ }++ @Model+ public final class WorkTypeEntity {+ public var id: UUID = UUID()+ public var name: String = ""+ public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var stateRaw: String = WorkTypeState.active.rawValue+ public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var canonicalID: UUID?+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++ public init() {}+ }++ @Model+ public final class Character {+ public var id: UUID = UUID()+ public var name: String = ""+ public var nameKey: String = ""+ public var aliases: [String] = []+ public var note: String = ""+ public var factsData: Data?+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+ public var work: Work?++ public init() {}+ }++ @Model+ public final class CharacterSuppression {+ public var id: UUID = UUID()+ public var work: Work?+ public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue+ public var nameKey: String = ""+ public var sourceKindRaw: String?+ public var sourceEntryID: UUID?+ public var evidence: String?+ public var statusRaw: String = CharacterSuppressionStatus.active.rawValue+ public var actionAt: Date = Date(timeIntervalSince1970: 0)++ public init() {}+ }++ @Model+ public final class WorkSiteMembership {+ public var id: UUID = UUID()+ public var hostname: String = ""+ public var createdAt: Date = Date(timeIntervalSince1970: 0)+ public var urlIdentity: String?+ public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue+ public var urlIdentityRuleID: UUID?+ public var workURLString: String?+ public var workID: UUID?+ public var work: Work?+ public var site: Site?++ public init() {}+ }++ @Model+ public final class WorkDistinctPair {+ public var id: UUID = UUID()+ public var lowerWorkID: UUID = UUID()+ public var higherWorkID: UUID = UUID()+ public var recordedAt: Date = Date(timeIntervalSince1970: 0)++ public init() {} } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex 6e1d817..97535b3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -4,7 +4,7 @@ import SwiftData private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupExport") -// The record projection every 8/9 export runs through, and the three refusals it+// The record projection every 9/10 export runs through, and the three refusals it // names. It stood in the 4/4 exporter while three generations shared it; // those generations are gone and this is the live export path, so it stands on // its own.@@ -15,14 +15,14 @@ private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupEx /// the Work mapper, plus the identity groups it needs. internal struct ArchiveCommonProjection { let groups: BackupGroupProjection.Projection- let entries: [BackupV8Entry]- let sites: [BackupV8Site]- let titlePatterns: [BackupV8TitlePattern]- let urlRules: [BackupV8URLRule]+ let entries: [BackupV9Entry]+ let sites: [BackupV9Site]+ let titlePatterns: [BackupV9TitlePattern]+ let urlRules: [BackupV9URLRule] /// One record per Work and hostname (Req 9.1), enumerated whole rather than /// works→children (Q17): a membership whose Work has not arrived exports /// naming the Work it belongs to instead of vanishing from the backup.- let memberships: [BackupV8Membership]+ let memberships: [BackupV9Membership] } /// Every Entry row's citations, decoded **once** for the whole projection.@@ -125,7 +125,7 @@ extension LibraryRepository { var additionalPatterns: [String: [TitlePattern]] = [:] for pattern in archivablePatterns where pattern.site == nil { guard let hostname = citers[pattern.id] else {- throw BackupV8ExportError.referencesStillArriving(+ throw BackupV9ExportError.referencesStillArriving( detail: "title rule \(pattern.id) has no site and no entry naming one") } additionalPatterns[hostname, default: []].append(pattern)@@ -133,7 +133,7 @@ extension LibraryRepository { var additionalURLRules: [String: [URLRulePattern]] = [:] for rule in archivableURLRules where rule.site == nil { guard let hostname = citers[rule.id] else {- throw BackupV8ExportError.referencesStillArriving(+ throw BackupV9ExportError.referencesStillArriving( detail: "URL rule \(rule.id) has no site and no record naming one") } additionalURLRules[hostname, default: []].append(rule)@@ -160,25 +160,25 @@ extension LibraryRepository { ruleMembership: .oneRowPerIdentityGroup) try requireProjectedTuplesRepresentable(projected) - var wireSites: [BackupV8Site] = []- var wirePatterns: [BackupV8TitlePattern] = []- var wireRules: [BackupV8URLRule] = []+ var wireSites: [BackupV9Site] = []+ var wirePatterns: [BackupV9TitlePattern] = []+ var wireRules: [BackupV9URLRule] = [] for site in projected {- wireSites.append(mapV8SiteRecord(site))+ wireSites.append(mapV9SiteRecord(site)) for projectedPattern in site.patterns where !omittedTitlePatternIDs.contains(projectedPattern.pattern.id) { wirePatterns.append(- try mapV8TitlePatternRecord(projectedPattern, hostname: site.hostname))+ try mapV9TitlePatternRecord(projectedPattern, hostname: site.hostname)) } for projectedRule in site.urlRules where !omittedURLRuleIDs.contains(projectedRule.rule.id) {- wireRules.append(try mapV8URLRuleRecord(projectedRule, hostname: site.hostname))+ wireRules.append(try mapV9URLRuleRecord(projectedRule, hostname: site.hostname)) } } return ArchiveCommonProjection( groups: groups,- entries: try groups.entries.map { try mapV8EntryRecord($0, citations: citations) },+ entries: try groups.entries.map { try mapV9EntryRecord($0, citations: citations) }, sites: wireSites.sorted { $0.hostname < $1.hostname }, titlePatterns: wirePatterns.sorted { $0.id.uuidString < $1.id.uuidString }, urlRules: wireRules.sorted { $0.id.uuidString < $1.id.uuidString },@@ -191,8 +191,9 @@ extension LibraryRepository { /// they read it — several of them coerce (`?? .conservative`) rather than /// throw, and a coerced value is silent data loss in a backup. ///- /// A Work's `typeRaw` is deliberately absent (Q8, Q34): V8 derives a type- /// from the work-type identity alone, so the column is not read at all.+ /// A Work's `typeRaw` is deliberately absent (Q8, Q34): the live schema+ /// (since V8) derives a type from the work-type identity alone, so the+ /// column is not read at all. /// /// The Entry's three provenance raws used to be checked here, because a row /// whose `citationsData` was nil fell back to the columns and read all three@@ -220,7 +221,7 @@ extension LibraryRepository { // than reached by a mapper that would throw a raw `DecodingError`. do { _ = try citations.value(of: entry) } catch {- throw BackupV8ExportError.unrepresentableValue(+ throw BackupV9ExportError.unrepresentableValue( record: record, field: "citations", value: String(describing: error)) } }@@ -228,6 +229,14 @@ extension LibraryRepository { let record = "Work \(work.id)" try require(TitleProvenance(rawValue: work.titleProvenanceRaw), record, "title provenance", work.titleProvenanceRaw)+ // Req 8.3. Both accessors read through `ToleratedEnum` so the app+ // can present a spelling a newer build wrote (Req 1.3, 2.7); the+ // archive may not, because writing the default back would record a+ // status the reader never chose as though they had.+ try require(WorkStatus(rawValue: work.workStatusRaw),+ record, "work status", work.workStatusRaw)+ try require(ReadingStatus(rawValue: work.readingStatusRaw),+ record, "reading status", work.readingStatusRaw) } for membership in memberships { // The identity state moved to the membership with the value it@@ -244,13 +253,14 @@ extension LibraryRepository { } for pattern in patterns { let record = "Title rule \(pattern.id)"- // V8 stores the whole arm as one blob (Q25), so the form is derived- // rather than a raw column of its own: a definition that decodes is- // a form the wire can spell, and one that does not is named here by- // its bytes rather than by a `formRaw` that no longer exists.+ // The live schema has stored the whole arm as one blob since V8+ // (Q25), so the form is derived rather than a raw column of its+ // own: a definition that decodes is a form the wire can spell, and+ // one that does not is named here by its bytes rather than by a+ // `formRaw` that no longer exists. do { _ = try pattern.storedDefinition } catch {- throw BackupV8ExportError.unrepresentableValue(+ throw BackupV9ExportError.unrepresentableValue( record: record, field: "definition", value: String(describing: error)) } }@@ -288,7 +298,7 @@ extension LibraryRepository { var omitted: Set<UUID> = [] for (id, rule) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) { guard !cited.contains(id) else {- throw BackupV8ExportError.unrepresentableValue(+ throw BackupV9ExportError.unrepresentableValue( record: "URL rule \(id)", field: "definition", value: "\(rule.definitionData.count) bytes that do not decode") }@@ -339,7 +349,7 @@ extension LibraryRepository { var omitted: Set<UUID> = [] for (id, pattern) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) { guard !pattern.isActive, !cited.contains(id) else {- throw BackupV8ExportError.unrepresentableValue(+ throw BackupV9ExportError.unrepresentableValue( record: "Title rule \(id)", field: "definition", value: pattern.definitionData.map { "\($0.count) bytes that do not decode" } ?? "no stored definition")@@ -395,7 +405,7 @@ extension LibraryRepository { _ value: Value?, _ record: String, _ field: String, _ raw: String ) throws { guard value == nil else { return }- throw BackupV8ExportError.unrepresentableValue(record: record, field: field, value: raw)+ throw BackupV9ExportError.unrepresentableValue(record: record, field: field, value: raw) } /// Req 3.7's third face: a hostname whose *projected* tuple the archive@@ -421,7 +431,7 @@ extension LibraryRepository { switch site.mode { case .taught: guard activePatterns != 1 else { continue }- throw BackupV8ExportError.referencesStillArriving(+ throw BackupV9ExportError.referencesStillArriving( detail: "site \(site.hostname) is taught, and the one active title rule " + "that state needs is not in the library") case .untaught:@@ -429,12 +439,12 @@ extension LibraryRepository { $0.rule.origin == .importedV2 && !$0.isCurrent } guard !site.patterns.isEmpty || currentRules > 0 || !historyOnly else { continue }- throw BackupV8ExportError.referencesStillArriving(+ throw BackupV9ExportError.referencesStillArriving( detail: "site \(site.hostname) is untaught while still holding rules, " + "so the teaching that owns them has not arrived") case .articles: guard activePatterns > 0 || currentRules > 0 else { continue }- throw BackupV8ExportError.referencesStillArriving(+ throw BackupV9ExportError.referencesStillArriving( detail: "site \(site.hostname) reads as articles while still holding an " + "active rule, so the change that cleared them has not arrived") }@@ -455,10 +465,10 @@ extension LibraryRepository { /// problem surfacing as a broken file, which is exactly what this gate /// exists to say first. internal static func requireCitationsResolve(- entries: [BackupV8Entry],- memberships: [BackupV8Membership],- titlePatterns: [BackupV8TitlePattern],- urlRules: [BackupV8URLRule]+ entries: [BackupV9Entry],+ memberships: [BackupV9Membership],+ titlePatterns: [BackupV9TitlePattern],+ urlRules: [BackupV9URLRule] ) throws { let rulesByID = Dictionary(urlRules.map { ($0.id, $0) }, uniquingKeysWith: { lhs, _ in lhs }) let patternHostnames = Dictionary(@@ -498,14 +508,14 @@ extension LibraryRepository { private static func crossSiteCitation( _ record: String, _ field: String, taughtFor hostname: String- ) -> BackupV8ExportError {+ ) -> BackupV9ExportError { .referencesStillArriving( detail: "\(record) names \(field), which is taught for \(hostname)") } private static func missingCitation( _ record: String, _ field: String- ) -> BackupV8ExportError {+ ) -> BackupV9ExportError { .referencesStillArriving( detail: "\(record) names \(field), which the library does not hold") }@@ -533,7 +543,7 @@ extension LibraryRepository { return map } - // MARK: - V8 Record Mappers+ // MARK: - V9 Record Mappers /// The record an Entry identity group archives as (Req 8.2): the /// representative row's capture evidence, the **group's** authored content,@@ -552,9 +562,9 @@ extension LibraryRepository { /// a citation of a rule the projection renumbered was archived at the /// version the archive actually held (Decision 7). A citation is a UUID /// (T-2281) and nothing renumbers, so the map and the parameter are gone.- internal static func mapV8EntryRecord(+ internal static func mapV9EntryRecord( _ group: EntryGroup, citations cache: EntryCitationsCache- ) throws -> BackupV8Entry {+ ) throws -> BackupV9Entry { let snap = try snapshot(group) let entry = group.representative let carrier = group.carrier@@ -564,7 +574,7 @@ extension LibraryRepository { citations.chapterTitle = carried.chapterTitle citations.workAssignment = carried.workAssignment }- return BackupV8Entry(+ return BackupV9Entry( id: snap.id, captureTitle: snap.captureTitle, captureTitleSource: snap.captureTitleSource,@@ -607,11 +617,11 @@ extension LibraryRepository { /// pointer to an entry the library does not hold exports verbatim with /// `typeName: nil` (Q24) — refusing there would fail an export at exactly /// the moment sync has not settled.- internal static func mapV8WorkRecord(+ internal static func mapV9WorkRecord( _ group: WorkGroup, canonicalWorkIDs: [UUID: UUID], types: WorkTypeDirectory- ) throws -> BackupV8Work {+ ) throws -> BackupV9Work { let snap = try snapshot(group, canonicalWorkIDs: canonicalWorkIDs, types: types) let assignment = WorkTypeAssignment.assignment(of: group.carrier) let workTypeID: UUID?@@ -622,13 +632,18 @@ extension LibraryRepository { case .configured(let id): (workTypeID, typeName) = (id, types.resolve(id)?.name) }- return BackupV8Work(+ return BackupV9Work( id: snap.id, displayTitle: snap.displayTitle, lastParsedTitle: snap.lastParsedTitle, genericNotes: snap.genericNotes, genreTags: snap.genreTags, titleProvenance: snap.titleProvenance,+ // Req 8.1: the carrier's three, as `snapshot` already resolved them+ // for a split group (Req 7.2).+ workStatus: snap.workStatus,+ readingStatus: snap.readingStatus,+ verdict: snap.verdict, workTypeID: workTypeID, typeName: typeName, createdAt: snap.createdAt,@@ -653,7 +668,7 @@ extension LibraryRepository { /// there. private static func mapMembershipRecords( _ rows: [WorkSiteMembership]- ) -> [BackupV8Membership] {+ ) -> [BackupV9Membership] { var byKey: [MembershipReconciler.Key: [WorkSiteMembership]] = [:] var unattributed: [WorkSiteMembership] = [] for row in rows {@@ -664,8 +679,8 @@ extension LibraryRepository { byKey[MembershipReconciler.Key(workID: workID, hostname: row.hostname), default: []] .append(row) }- func record(_ row: WorkSiteMembership, workURLString: String?) -> BackupV8Membership {- BackupV8Membership(+ func record(_ row: WorkSiteMembership, workURLString: String?) -> BackupV9Membership {+ BackupV9Membership( id: row.id, workID: row.resolvedWorkID, hostname: row.hostname, createdAt: row.createdAt, urlIdentity: row.urlIdentity, urlIdentityState: row.urlIdentityState,@@ -680,7 +695,7 @@ extension LibraryRepository { // [4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42) // cannot survive. Nothing is written back: the fold returns the value // and the record carries it (Q54).- var records: [BackupV8Membership] = []+ var records: [BackupV9Membership] = [] for rows in byKey.values { let ordered = MembershipReconciler.survivorFirst(rows) guard let keeper = ordered.first else { continue }@@ -709,7 +724,7 @@ extension LibraryRepository { /// nothing: a Work is not distinct from itself. internal static func projectDistinctPairs( context: ModelContext- ) throws -> [BackupV8DistinctPair] {+ ) throws -> [BackupV9DistinctPair] { var byKey: [WorkPairKey: [WorkDistinctPair]] = [:] for row in try context.fetch(FetchDescriptor<WorkDistinctPair>()) where row.lowerWorkID != row.higherWorkID {@@ -721,7 +736,7 @@ extension LibraryRepository { guard let survivor = MembershipReconciler.survivorFirstPairs(rows).first else { return nil }- return BackupV8DistinctPair(+ return BackupV9DistinctPair( id: survivor.id, lowerWorkID: key.lower, higherWorkID: key.higher, recordedAt: survivor.recordedAt) }@@ -731,10 +746,10 @@ extension LibraryRepository { /// The wire Site for a hostname: exactly one, whatever the store holds /// (Q38). It names no children (Req 9.3) — the union still decides which /// rules are archived, and each of them names this hostname back.- internal static func mapV8SiteRecord(+ internal static func mapV9SiteRecord( _ projected: SiteUnionProjection.ProjectedSite- ) -> BackupV8Site {- BackupV8Site(+ ) -> BackupV9Site {+ BackupV9Site( hostname: projected.hostname, displayName: projected.displayName, mode: projected.mode,@@ -742,10 +757,10 @@ extension LibraryRepository { ) } - internal static func mapV8TitlePatternRecord(+ internal static func mapV9TitlePatternRecord( _ projected: SiteUnionProjection.ProjectedTitlePattern, hostname: String- ) throws -> BackupV8TitlePattern {- BackupV8TitlePattern(+ ) throws -> BackupV9TitlePattern {+ BackupV9TitlePattern( id: projected.pattern.id, siteHostname: hostname, // The version stored on the row the per-UUID reduction kept, without@@ -760,17 +775,17 @@ extension LibraryRepository { /// `throws` because the definition does (Req 4.5): the mapper needs a typed /// `URLRuleDefinition` for every row it writes, so a row that will not /// decode cannot be archived at all.- internal static func mapV8URLRuleRecord(+ internal static func mapV9URLRuleRecord( _ projected: SiteUnionProjection.ProjectedURLRule, hostname: String- ) throws -> BackupV8URLRule {+ ) throws -> BackupV9URLRule { let definition: URLRuleDefinition do { definition = try projected.rule.definition } catch {- throw BackupV8ExportError.unrepresentableValue(+ throw BackupV9ExportError.unrepresentableValue( record: "URL rule \(projected.rule.id)", field: "definition", value: "\(projected.rule.definitionData.count) bytes that do not decode") }- return BackupV8URLRule(+ return BackupV9URLRule( id: projected.rule.id, version: projected.rule.version, isCurrent: projected.isCurrent,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swiftindex 1718cdc..4b53ab3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift@@ -24,13 +24,13 @@ internal enum BackupArchiveReferenceChecks { /// refusal — the one message here that has to say which archive format it /// is talking about. static func validate(- entries: [BackupV8Entry],- works: [BackupV8Work],- memberships: [BackupV8Membership],- distinctPairs: [BackupV8DistinctPair],- sites: [BackupV8Site],- titlePatterns: [BackupV8TitlePattern],- urlRules: [BackupV8URLRule],+ entries: [BackupV9Entry],+ works: [BackupV9Work],+ memberships: [BackupV9Membership],+ distinctPairs: [BackupV9DistinctPair],+ sites: [BackupV9Site],+ titlePatterns: [BackupV9TitlePattern],+ urlRules: [BackupV9URLRule], formatLabel: String ) throws { let siteHostnames = Set(sites.map(\.hostname))@@ -138,9 +138,9 @@ internal enum BackupArchiveReferenceChecks { // MARK: Site closed tuple (supersedes M3 8.1) private static func validateSiteTuple(- _ site: BackupV8Site,- patterns: [BackupV8TitlePattern],- rules: [BackupV8URLRule]+ _ site: BackupV9Site,+ patterns: [BackupV9TitlePattern],+ rules: [BackupV9URLRule] ) throws { let id = site.hostname guard !M2Unicode.isBlank(site.hostname) else { throw invalid("Site", id, "hostname is blank") }@@ -205,9 +205,9 @@ internal enum BackupArchiveReferenceChecks { /// (Req 9.5, Q22); its own tuple is still checked, because an orphan is a /// row like any other. private static func validateMembership(- _ membership: BackupV8Membership,+ _ membership: BackupV9Membership, siteHostnames: Set<String>,- rulesByID: [UUID: BackupV8URLRule]+ rulesByID: [UUID: BackupV9URLRule] ) throws { let id = membership.id.uuidString guard !M2Unicode.isBlank(membership.hostname) else {@@ -236,12 +236,12 @@ internal enum BackupArchiveReferenceChecks { // MARK: Entry (Entry-state enumeration, supersedes M3 8.12) private static func validateEntry(- _ entry: BackupV8Entry,+ _ entry: BackupV9Entry, siteHostnames: Set<String>, workIDs: Set<UUID>, hostnamesByWork: [UUID: Set<String>],- patternsByID: [UUID: BackupV8TitlePattern],- rulesByID: [UUID: BackupV8URLRule]+ patternsByID: [UUID: BackupV9TitlePattern],+ rulesByID: [UUID: BackupV9URLRule] ) throws { let id = entry.id.uuidString guard siteHostnames.contains(entry.hostname) else {@@ -322,15 +322,15 @@ internal enum BackupArchiveReferenceChecks { /// dangling citation is an unresolved reference — so the predicate is stated /// once and each caller names its own failure. private static func resolvesSameSite(- _ cited: CitedRule, entry: BackupV8Entry, rulesByID: [UUID: BackupV8URLRule]+ _ cited: CitedRule, entry: BackupV9Entry, rulesByID: [UUID: BackupV9URLRule] ) -> Bool { rulesByID[cited.id]?.siteHostname == entry.hostname } private static func requireSameSiteRule( _ cited: CitedRule,- entry: BackupV8Entry,- rulesByID: [UUID: BackupV8URLRule]+ entry: BackupV9Entry,+ rulesByID: [UUID: BackupV9URLRule] ) throws { guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else { throw invalid(@@ -340,10 +340,10 @@ internal enum BackupArchiveReferenceChecks { } private static func validateEntryRuleReference(- _ entry: BackupV8Entry,+ _ entry: BackupV9Entry, field: String, cited: CitedRule?,- rulesByID: [UUID: BackupV8URLRule]+ rulesByID: [UUID: BackupV9URLRule] ) throws { guard let cited else { return } guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftindex 85d1ada..4c8782b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -1,7 +1,7 @@ import Foundation /// The file a completed backup export produced, handed to the share sheet and-/// cleaned up afterwards. `BackupV8Exporter` is the only producer.+/// cleaned up afterwards. `BackupV9Exporter` is the only producer. public struct BackupExportResult: Sendable { public let fileURL: URL
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swiftindex f3d00d4..1c9df35 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift@@ -72,7 +72,7 @@ enum BackupGroupProjection { let characters: [CharacterGroup] } - /// - Throws: `BackupV8ExportError.tornGroups` when the store holds a torn+ /// - Throws: `BackupV9ExportError.tornGroups` when the store holds a torn /// group — the biconditional of Req 8.1, since nothing else here refuses. /// - Parameter distinctPairs: the reader's recorded "not the same work" /// dismissals (Req 5.6). **Not defaulted**: an export that cannot see them@@ -103,7 +103,7 @@ enum BackupGroupProjection { // no site at all and a torn character would export one variant silently. let tornCharacters = characterGroups.values.filter(\.isTorn) guard tornEntries.isEmpty, tornWorks.isEmpty, tornCharacters.isEmpty else {- throw BackupV8ExportError.tornGroups(+ throw BackupV9ExportError.tornGroups( tornGroupsPayload( tornEntries: tornEntries, tornWorks: tornWorks, tornCharacters: tornCharacters,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swiftindex 0b1a12d..1436f82 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift@@ -116,7 +116,7 @@ extension LibraryRepository { /// `nameKey` travels rather than being re-derived: it is retained through /// renames (Q19/Q46), and recomputing it from `name` would silently re-key /// every character an archive restored.- internal static func apply(_ record: BackupV8Character, to character: CharacterRecord) {+ internal static func apply(_ record: BackupV9Character, to character: CharacterRecord) { character.name = record.name character.nameKey = record.nameKey character.aliases = record.aliases@@ -126,7 +126,7 @@ extension LibraryRepository { character.modifiedAt = record.modifiedAt } - internal static func apply(_ record: BackupV8Suppression, to row: CharacterSuppression) {+ internal static func apply(_ record: BackupV9Suppression, to row: CharacterSuppression) { row.kindRaw = record.kindRaw row.nameKey = record.nameKey row.sourceKindRaw = record.sourceKindRaw
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swiftindex e74caf8..6f2e5d3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift@@ -31,8 +31,8 @@ extension LibraryRepository { /// restore of an entry this library already held. That one write is the /// reader acting now, and it has to assert over a removal older than it. internal static func mergeImportedWorkTypes(- workTypes: [BackupV8WorkType],- works: [BackupV8Work],+ workTypes: [BackupV9WorkType],+ works: [BackupV9Work], exportedAt: Date, importedAt: Date, context: ModelContext,@@ -171,7 +171,7 @@ extension LibraryRepository { /// Returned in identifier order, so an interrupted import resumes into the /// same shape on any device. private static func archivedTypeIdentities(- _ records: [BackupV8WorkType]+ _ records: [BackupV9WorkType] ) -> [ArchivedTypeIdentity] { let directory = WorkTypeDirectory( rows: records.map {@@ -266,7 +266,7 @@ extension LibraryRepository { /// A citation with no snapshot is deliberately absent: it stays on the work /// as unresolved rather than being invented a name (Q24). private static func unresolvedTypeCitations(- _ works: [BackupV8Work], in local: WorkTypeDirectory+ _ works: [BackupV9Work], in local: WorkTypeDirectory ) -> [ArchivedTypeCitation] { var seen: Set<UUID> = [] var citations: [ArchivedTypeCitation] = []
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex 7cf012c..045058a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -11,33 +11,33 @@ import OSLog /// longer exist and every accessor answered the same arm three times. What /// remains is the payload's arrays, named. ///-/// This is `BackupV8Payload`'s content rather than the type itself: the wire+/// This is `BackupV9Payload`'s content rather than the type itself: the wire /// struct is a `Codable` frozen shape and the plan is what the commit reads, and /// keeping them separate is what lets a future generation arrive without the /// upsert learning its envelope. public struct BackupImportPayload: Sendable, Equatable {- public let entries: [BackupV8Entry]- public let works: [BackupV8Work]- public let sites: [BackupV8Site]- public let titlePatterns: [BackupV8TitlePattern]- public let urlRules: [BackupV8URLRule]- public let workTypes: [BackupV8WorkType]- public let memberships: [BackupV8Membership]- public let distinctPairs: [BackupV8DistinctPair]- public let characters: [BackupV8Character]- public let suppressions: [BackupV8Suppression]+ public let entries: [BackupV9Entry]+ public let works: [BackupV9Work]+ public let sites: [BackupV9Site]+ public let titlePatterns: [BackupV9TitlePattern]+ public let urlRules: [BackupV9URLRule]+ public let workTypes: [BackupV9WorkType]+ public let memberships: [BackupV9Membership]+ public let distinctPairs: [BackupV9DistinctPair]+ public let characters: [BackupV9Character]+ public let suppressions: [BackupV9Suppression] public init(- entries: [BackupV8Entry],- works: [BackupV8Work],- sites: [BackupV8Site],- titlePatterns: [BackupV8TitlePattern],- urlRules: [BackupV8URLRule],- workTypes: [BackupV8WorkType] = [],- memberships: [BackupV8Membership] = [],- distinctPairs: [BackupV8DistinctPair] = [],- characters: [BackupV8Character] = [],- suppressions: [BackupV8Suppression] = []+ entries: [BackupV9Entry],+ works: [BackupV9Work],+ sites: [BackupV9Site],+ titlePatterns: [BackupV9TitlePattern],+ urlRules: [BackupV9URLRule],+ workTypes: [BackupV9WorkType] = [],+ memberships: [BackupV9Membership] = [],+ distinctPairs: [BackupV9DistinctPair] = [],+ characters: [BackupV9Character] = [],+ suppressions: [BackupV9Suppression] = [] ) { self.entries = entries self.works = works@@ -55,7 +55,7 @@ public struct BackupImportPayload: Sendable, Equatable { self.suppressions = suppressions } - public init(_ payload: BackupV8Payload) {+ public init(_ payload: BackupV9Payload) { self.init( entries: payload.entries, works: payload.works, sites: payload.sites, titlePatterns: payload.titlePatterns, urlRules: payload.urlRules,@@ -82,11 +82,10 @@ public struct BackupImportPayload: Sendable, Equatable { /// process lease. Represents a complete validated prospective graph ready to be /// materialized atomically. ///-/// One source version is accepted, `8/9`. Every earlier generation's read path-/// has been retired in turn, `7/8` included (Q14): an Entry citation is the-/// cited rule's UUID now, and a 7/8 record pins it at `(id, version)` — a pair-/// nothing here resolves. Recovering an older archive means a build that still-/// carries its codec.+/// One source version is accepted, `9/10`. Every earlier generation's read path+/// has been retired in turn, `8/9` included (Q17): a Work carries a work status,+/// a reading status and a verdict now, and an 8/9 record holds none of them.+/// Recovering an older archive means a build that still carries its codec. public struct BackupImportPlan: Sendable, Equatable { public let metadata: BackupImportMetadata public let payload: BackupImportPayload@@ -103,7 +102,7 @@ public struct BackupImportPlan: Sendable, Equatable { /// A plan over a wire payload, which is how every archive reaches one. public init(- metadata: BackupImportMetadata, payload: BackupV8Payload,+ metadata: BackupImportMetadata, payload: BackupV9Payload, counts: LibraryRecordCounts ) { self.init(@@ -183,16 +182,18 @@ public enum BackupImportError: Error, Equatable, Sendable, CustomStringConvertib /// repository actor and without a process lease. Never mutates the selected /// file. ///-/// Import supports exact native `8/9` and nothing else. Mixed pairs, older+/// Import supports exact native `9/10` and nothing else. Mixed pairs, older /// generations and future headers reject before repository mutation — which is-/// the same door a *pre-feature* build meets `(8, 9)` at, and why an 8/9 archive-/// cannot half-apply on one (Req 5.2).+/// the same door a *pre-feature* build meets `(9, 10)` at, and why a 9/10+/// archive cannot half-apply on one (Req 8.2). public enum BackupImporter { private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImporter") - /// The pair this app reads and writes.+ /// The pair this app reads and writes: format 9 over schema 10. It follows+ /// the document's own constants, so the one accepted pair moves with the+ /// generation rather than being restated here. private static let supportedVersions = (- format: BackupV8Document.formatVersion, schema: BackupV8Document.schemaVersion+ format: BackupV9Document.formatVersion, schema: BackupV9Document.schemaVersion ) // MARK: - Plan Dispatch (Req 5.1, 5.2, Decision 2)@@ -223,9 +224,9 @@ public enum BackupImporter { } private static func planFromArchive(_ data: Data) throws -> BackupImportPlan {- let document: BackupV8Document+ let document: BackupV9Document do {- document = try BackupV8Codec.decode(data)+ document = try BackupV9Codec.decode(data) } catch { throw BackupImportError.decodingFailed(reason: String(describing: error)) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swiftindex a5a48a5..eb6190d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift@@ -1,7 +1,7 @@ import Foundation // Extracted from the retired `LegacyBackupV2Codec` when the 2/2 and 3/3 import-// paths were removed. Both helpers are used by the live `BackupV8Codec`: the+// paths were removed. Both helpers are used by the live `BackupV9Codec`: the // date formatter fixes the archive's timestamp encoding, and the duplicate-key // validator is what makes a decode strict rather than last-key-wins. //@@ -165,7 +165,7 @@ internal enum BackupArchiveDateFormatter { /// the live decode path: `BackupArchiveShapeValidator` goes through /// `JSONSerialization`, which collapses duplicates without complaint, so this is /// the only thing standing between a two-`payload` archive and importing the-/// wrong one. It also enforces no-trailing-bytes. `BackupV8ArchiveTests` covers+/// wrong one. It also enforces no-trailing-bytes. `BackupV9ArchiveTests` covers /// both properties by editing encoded bytes directly — they cannot be reached /// through any `JSONSerialization` round-trip. internal struct DuplicateJSONKeyValidator {
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV9Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Codec.swiftnew file mode 100644index 0000000..27ce0a2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Codec.swift@@ -0,0 +1,224 @@+import Foundation++/// The strict 9/10 archive codec: canonical JSON, a SHA-256 checksum over the+/// payload bytes, entry/work counts, root-strict envelope validation, typed+/// nested decode, and a reference validator over the shared record checks plus+/// this generation's own rules.+///+/// The only codec. It was cloned from the 8/9 one rather than grown out of it —+/// a shipped archive format is never redefined in place — and 8/9 was deleted+/// with the Work record it described (Q17, Q34). What it shares with nothing in+/// particular, because there is nothing else, is stated once beside it: the+/// record-level checking body (`BackupArchiveReferenceChecks`), the envelope+/// shape (`BackupArchiveShapeValidator`) and the canonical JSON settings.+///+/// The capability gate is pinned to the literal `"multi-site"`: the payload is+/// frozen the moment it ships, and a later `AsterismCapabilities.current` must+/// not change what a 9/10 backup declares. 9/10 changes neither the store shape+/// the gate names nor the rule-form set, so it keeps stamping it+/// (`rule-citation-by-uuid` Q19).+public enum BackupV9Codec {+ /// Pinned literally. 9/10 ships at the multi-site gate; a future gate flip+ /// cannot retroactively change what these files say.+ static let gate = "multi-site"++ /// The generation this codec speaks, as it appears in decode diagnostics.+ static let label = "V9"++ // MARK: - Encode++ public static func encode(+ payload: BackupV9Payload,+ metadata: BackupV9Metadata+ ) throws -> Data {+ let encoder = BackupCanonicalJSON.encoder()++ let payloadData = try encoder.encode(payload)+ let checksum = BackupCanonicalJSON.sha256Hex(payloadData)++ let document = BackupV9Document(+ 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 9/10 document. Validates: envelope format/schema,+ /// capability gate, duplicate keys, strict root shape, entry/work counts,+ /// payload checksum, and all references and tuples.+ ///+ /// The version pair is exact. `(9, 9)` and `(8, 10)` are rejected here, not+ /// only at the importer's dispatch: a mismatched pair is a file this codec+ /// cannot claim to understand whichever door it arrived through.+ ///+ /// The checksum step is also a refusal in its own right: it is taken over+ /// the bytes as they arrived and compared against a re-encoding of what+ /// decoded, so a 9/10 file carrying a key this build does not write back —+ /// a citation `version`, the shape a build before T-2281 wrote — fails here+ /// rather than importing with the key silently dropped.+ public static func decode(_ data: Data) throws -> BackupV9Document {+ do {+ try DuplicateJSONKeyValidator.validate(data)+ try BackupArchiveShapeValidator.validate(data)++ let document = try BackupCanonicalJSON.decoder()+ .decode(BackupV9Document.self, from: data)++ guard document.backupFormatVersion == BackupV9Document.formatVersion else {+ throw BackupCodecError.invalidFormatVersion(document.backupFormatVersion)+ }+ guard document.databaseSchemaVersion == BackupV9Document.schemaVersion else {+ throw BackupCodecError.invalidSchemaVersion(document.databaseSchemaVersion)+ }+ guard document.capabilityGate == Self.gate else {+ throw BackupCodecError.unsupportedGate(document.capabilityGate)+ }++ guard document.entryCount == document.payload.entries.count else {+ throw BackupCodecError.countMismatch(+ field: "entryCount",+ expected: document.entryCount,+ actual: document.payload.entries.count+ )+ }+ guard document.workCount == document.payload.works.count else {+ throw BackupCodecError.countMismatch(+ field: "workCount",+ expected: document.workCount,+ actual: document.payload.works.count+ )+ }++ // Verify checksum: re-encode payload with the same settings.+ let payloadData = try BackupCanonicalJSON.encoder().encode(document.payload)+ let computedChecksum = BackupCanonicalJSON.sha256Hex(payloadData)+ guard document.checksum == computedChecksum else {+ throw BackupCodecError.checksumMismatch(+ expected: document.checksum,+ actual: computedChecksum+ )+ }++ try BackupV9ReferenceValidator.validate(payload: document.payload)++ return document+ } catch let error as BackupCodecError { throw error }+ catch {+ throw BackupCodecError.decodingFailed(reason: String(describing: error))+ }+ }+}++// MARK: - V9 Metadata++public struct BackupV9Metadata: Sendable {+ public let appBuild: String+ public let exportedAt: Date++ public init(appBuild: String, exportedAt: Date) {+ self.appBuild = appBuild+ self.exportedAt = exportedAt+ }+}++// MARK: - Shape Validator++/// 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 BackupArchiveShapeValidator {+ 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",+ ]+ if let unknown = Set(root.keys).subtracting(required).sorted().first {+ throw BackupCodecError.unknownKey("$.\(unknown)")+ }+ if let missing = required.subtracting(root.keys).sorted().first {+ throw BackupCodecError.missingKey("$.\(missing)")+ }+ }+}++// MARK: - V9 Reference Validator++/// The shared record checks, the type-list rules, and the two character arrays.+///+/// **What it deliberately does not check.** A fact's `sourceEntryID` and a fact+/// suppression's are exempt (`character-extraction` Decision 2): a citation+/// whose entry the reader deleted — or whose entry has not synced — is a+/// tolerated state, not corruption, and refusing here would fail a whole backup+/// over routine curation.+///+/// What it does refuse is a payload contradicting itself: two records for one+/// character or suppression, and a character or suppression naming a Work the+/// file does not hold. The work reference is **optional, checked when present**+/// — the `validateEntry` `workID` pattern — so an orphan passes.+internal enum BackupV9ReferenceValidator {+ static func validate(payload: BackupV9Payload) throws {+ do {+ try BackupArchiveReferenceChecks.validate(+ entries: payload.entries,+ works: payload.works,+ memberships: payload.memberships,+ distinctPairs: payload.distinctPairs,+ sites: payload.sites,+ titlePatterns: payload.titlePatterns,+ urlRules: payload.urlRules,+ formatLabel: BackupV9Codec.label)+ } catch let issue as BackupArchiveReferenceIssue {+ throw BackupCodecError(issue)+ }++ let typeIDs = Set(payload.workTypes.map(\.id))+ guard typeIDs.count == payload.workTypes.count else {+ throw BackupCodecError.invalidStateTuple(+ type: "Payload", id: BackupV9Codec.label, reason: "duplicate work type ID")+ }++ let workIDs = Set(payload.works.map(\.id))++ var characterIDs: Set<UUID> = []+ for character in payload.characters {+ guard characterIDs.insert(character.id).inserted else {+ throw BackupCodecError.invalidStateTuple(+ type: "Payload", id: BackupV9Codec.label, reason: "duplicate Character ID")+ }+ if let workID = character.workID, !workIDs.contains(workID) {+ throw BackupCodecError.unresolvedReference(+ type: "Character", id: character.id.uuidString, reference: "Work \(workID)")+ }+ }++ var suppressionIDs: Set<UUID> = []+ for suppression in payload.suppressions {+ guard suppressionIDs.insert(suppression.id).inserted else {+ throw BackupCodecError.invalidStateTuple(+ type: "Payload", id: BackupV9Codec.label, reason: "duplicate CharacterSuppression ID")+ }+ if let workID = suppression.workID, !workIDs.contains(workID) {+ throw BackupCodecError.unresolvedReference(+ type: "CharacterSuppression", id: suppression.id.uuidString,+ reference: "Work \(workID)")+ }+ }+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV9Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Exporter.swiftnew file mode 100644index 0000000..0f0db9b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Exporter.swift@@ -0,0 +1,265 @@+import Foundation+import SwiftData++// MARK: - Snapshot Providing++/// Provides one coherent 9/10 payload under a shared lock. Isolated from+/// persistence so export can be unit-tested with injected snapshots.+public protocol BackupV9SnapshotProviding: Sendable {+ func backupV9Snapshot() async throws -> BackupV9Payload+}++// MARK: - Export Errors++/// The export's refusals — the three states a backup can decline over, and the+/// three ways the machinery around it can fail.+///+/// One enum, not one per generation. It used to be three, each restating the+/// same six cases and each carrying an initializer that renamed another+/// generation's finding into its own. The distinctions a reader is shown are all+/// here — what is gone is the per-generation prefix, which said nothing the+/// detected format pair does not already say.+///+/// `tornGroups` covers a **character** group whose rows disagree about something+/// the reader wrote exactly as it covers a torn Entry or Work. What does *not*+/// refuse is a fact whose citation dangles — `character-extraction` Decision 2+/// makes that a tolerated state.+public enum BackupV9ExportError: Error, Equatable, Sendable, CustomStringConvertible {+ /// The store holds a **torn** identity group: one application UUID over rows+ /// that disagree about something the reader wrote. The archive keys records+ /// by UUID and cannot hold both variants, and silently dropping one is data+ /// loss inside a backup.+ case tornGroups(TornGroupsPayload)+ /// A record holds a stored value the wire format cannot represent —+ /// typically an enum raw value a newer app version wrote and synced down, or+ /// a Codable blob it cannot decode. Omitting the record is silent data loss;+ /// representing the value is a format change. So the record and the value+ /// are named and the export refuses.+ case unrepresentableValue(record: String, field: String, value: String)+ /// A record cites a rule no row in the library holds, or a hostname whose+ /// projected tuple the format has no case for. Transient by nature: the+ /// missing row is en route.+ case referencesStillArriving(detail: String)++ case snapshotFailed(reason: String)+ case encodingFailed(reason: String)+ case stagingFailed(reason: String)++ public var description: String {+ switch self {+ case .tornGroups(let payload):+ payload.count == 1+ ? "Backup export refused: 1 record exists in differing copies, and a "+ + "backup cannot hold both"+ : "Backup export refused: \(payload.count) records exist in differing "+ + "copies, and a backup cannot hold them all"+ case .unrepresentableValue(let record, let field, let value):+ "Backup export refused: \(record) holds \(field) '\(value)', which this "+ + "backup format cannot represent — it was probably written by a newer "+ + "version of Asterism"+ case .referencesStillArriving(let detail):+ "Backup export refused: records are still arriving from iCloud (\(detail)). "+ + "Try again once syncing has settled"+ case .snapshotFailed(let reason): "Backup snapshot failed: \(reason)"+ case .encodingFailed(let reason): "Backup encoding failed: \(reason)"+ case .stagingFailed(let reason): "Backup staging failed: \(reason)"+ }+ }+}++// MARK: - LibraryRepository Snapshot++extension LibraryRepository: BackupV9SnapshotProviding {+ /// Provides a coherent 9/10 backup payload under a shared lock.+ ///+ /// **The quarantine and unresolved gates are gone** (Req 3.1 of+ /// `cloudkit-mirroring`). They refused a file at exactly the moment one is+ /// most wanted: an ordinary sync quarantines a hostname or leaves 2,995 of+ /// 3,000 records holding an unresolved Site reference, and the backup tool+ /// then declined. What made removing them possible is `SiteUnionProjection`+ /// — duplicate rows project to one wire Site, rowless hostnames to a+ /// synthesised untaught one, and nil-site rules attach through their citers.+ ///+ /// Three refusals remain, each named: a torn identity group (3.3), a stored+ /// value the format cannot represent (3.6), and citations that do not+ /// resolve (3.7).+ ///+ /// Export never writes. The projection is computed read-side precisely so a+ /// backup cannot mutate the library on the way out (Q38); the archive it+ /// produces is nonetheless the shape reconciliation settles on, which is what+ /// makes Req 3.5's round-trip hold.+ public func backupV9Snapshot() async throws -> BackupV9Payload {+ let outcome: Result<BackupV9Payload, BackupV9ExportError> =+ try await withLockedBackupContext { context in+ do { return .success(try Self.projectV9Payload(context: context)) }+ catch let error as BackupV9ExportError { return .failure(error) }+ }+ return try outcome.get()+ }++ /// The whole 9/10 snapshot, from a context. Static and pure so the projection+ /// can be exercised without an actor.+ ///+ /// **One projection pass.** `projectCommonArchiveRecords` already enumerated+ /// every character and every membership whole (Q17, Q78 — never+ /// works→children, so a sync orphan exports naming its Work instead of+ /// vanishing), already refused a torn group, and already sorted by UUID.+ /// Re-deriving any of it would be a second walk of the two largest tables and+ /// a second chance to describe two moments.+ internal static func projectV9Payload(context: ModelContext) throws -> BackupV9Payload {+ let common = try projectCommonArchiveRecords(context: context)++ // Req 7.2 and Q32: the **folded** list, one record per identity. Rows+ // sharing a UUID are a normal permanent state in the live store — the+ // strict duplicate arm applies to materialized archives, which is what+ // this fold produces. `identities` is ordered by identifier, so two+ // devices holding the same rows write the same bytes.+ let directory = common.groups.types+ let workTypes = directory.identities.map {+ BackupV9WorkType(+ id: $0.id, name: $0.name, stateRaw: $0.state.rawValue,+ canonicalID: $0.canonicalID, createdAt: $0.createdAt,+ modifiedAt: $0.modifiedAt)+ }++ let works = try common.groups.works.map {+ try mapV9WorkRecord(+ $0, canonicalWorkIDs: common.groups.canonicalWorkIDs, types: directory)+ }++ // Req 3.7: the archive's own reference validator refuses a citation that+ // does not resolve, and the import gates refuse such a file. Discovering+ // that inside export's verify-decode would surface a library-shape+ // problem as a codec error, so it is named here instead.+ try requireCitationsResolve(+ entries: common.entries,+ memberships: common.memberships,+ titlePatterns: common.titlePatterns,+ urlRules: common.urlRules)++ let characters = common.groups.characters.map(mapV9CharacterRecord)++ let suppressions = try context.fetch(FetchDescriptor<CharacterSuppression>())+ .map(mapV9SuppressionRecord)+ .sorted { $0.id.uuidString < $1.id.uuidString }++ return BackupV9Payload(+ entries: common.entries,+ works: works,+ sites: common.sites,+ titlePatterns: common.titlePatterns,+ urlRules: common.urlRules,+ workTypes: workTypes,+ memberships: common.memberships,+ distinctPairs: try projectDistinctPairs(context: context),+ characters: characters,+ suppressions: suppressions)+ }++ /// The group's presented content, plus the immutable evidence its carrier+ /// holds. One record per identity, like every other archive record: rows+ /// sharing a UUID are one character everywhere else in the app.+ private static func mapV9CharacterRecord(_ group: CharacterGroup) -> BackupV9Character {+ let content = group.presentedContent+ return BackupV9Character(+ id: group.id,+ workID: group.carrier.work?.id,+ name: content.name,+ nameKey: group.carrier.nameKey,+ aliases: content.aliases,+ note: content.note,+ facts: content.facts,+ createdAt: group.createdAt,+ modifiedAt: group.modifiedAt)+ }++ /// Suppression rows travel one-for-one, duplicates included (Q82): the store+ /// reads them through by `actionAt` rather than folding them, so folding+ /// here would be a second convergence rule that only archives obey.+ private static func mapV9SuppressionRecord(+ _ row: CharacterSuppression+ ) -> BackupV9Suppression {+ BackupV9Suppression(+ id: row.id, workID: row.work?.id, kindRaw: row.kindRaw, nameKey: row.nameKey,+ sourceKindRaw: row.sourceKindRaw, sourceEntryID: row.sourceEntryID,+ evidence: row.evidence, statusRaw: row.statusRaw, actionAt: row.actionAt)+ }+}++// MARK: - The Exporter++/// Orchestrates coherent 9/10 snapshot → validated encoding → staging.+///+/// The only exporter. It decode-validates its own bytes before sharing, so a+/// produced file is always a valid strict 9/10 document.+public final class BackupV9Exporter: Sendable {+ private let repository: any BackupV9SnapshotProviding+ private let stagingDirectory: URL++ public init(+ repository: any BackupV9SnapshotProviding,+ stagingDirectory: URL+ ) {+ self.repository = repository+ self.stagingDirectory = stagingDirectory+ }++ public func export(metadata: BackupV9Metadata) async throws -> BackupExportResult {+ let payload: BackupV9Payload+ do {+ payload = try await repository.backupV9Snapshot()+ } catch let error as BackupV9ExportError {+ throw error+ } catch {+ throw BackupV9ExportError.snapshotFailed(reason: String(describing: error))+ }++ let encoded: Data+ do {+ encoded = try BackupV9Codec.encode(payload: payload, metadata: metadata)+ } catch {+ throw BackupV9ExportError.encodingFailed(reason: String(describing: error))+ }++ do {+ let decoded = try BackupV9Codec.decode(encoded)+ guard decoded.payload == payload else {+ throw BackupV9ExportError.encodingFailed(reason: "decode-validation payload mismatch")+ }+ } catch let error as BackupV9ExportError {+ throw error+ } catch {+ throw BackupV9ExportError.encodingFailed(reason: "decode-validation failed: \(error)")+ }++ do {+ try FileManager.default.createDirectory(+ at: stagingDirectory, withIntermediateDirectories: true)+ let fileURL = stagingDirectory.appending(+ path: ExportStaging.backupFilename(+ version: "v9", exportedAt: metadata.exportedAt))+ do {+ try ExportStaging.write(encoded, to: fileURL)+ } catch {+ throw BackupV9ExportError.stagingFailed(reason: String(describing: error))+ }+ return BackupExportResult(fileURL: fileURL)+ } catch let error as BackupV9ExportError {+ throw error+ } catch {+ throw BackupV9ExportError.stagingFailed(+ reason: "preparing staging directory failed: \(error)")+ }+ }++ public func cleanup(_ result: BackupExportResult) {+ try? FileManager.default.removeItem(at: result.fileURL)+ }++ /// Removes abandoned backup files older than 24 hours from the staging area,+ /// which still covers the files a previous build staged at an earlier+ /// generation.+ public func scavengeStaleFiles() {+ ExportStaging.scavengeBackups(in: stagingDirectory)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV9Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Types.swiftnew file mode 100644index 0000000..fc5ef0b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV9Types.swift@@ -0,0 +1,566 @@+import Foundation++// MARK: - Backup V9 Document++/// The 9/10 backup envelope: format version 9 over schema version 10+/// (`work-and-reading-status` Req 8.1, Q17, Q34). The schema number names the+/// store schema the archive was taken from, which is V10.+///+/// It **replaces** the 8/9 set outright rather than standing beside it+/// (`rule-citation-by-uuid` Q14, restated here by Q17): a Work carries a work+/// status, a reading status and a verdict now, and an 8/9 file holds none of+/// them — values this build would have to invent. An archive written before+/// 9/10 is refused by version, with the message naming the pair it declares+/// (Req 8.2).+///+/// The envelope keys are 4/4's, unchanged through every generation since.+/// Everything this one changes is inside `payload`.+public struct BackupV9Document: Codable, Equatable, Sendable {+ public static let formatVersion = 9+ public static let schemaVersion = 10++ 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: BackupV9Payload++ public init(+ appBuild: String,+ exportedAt: Date,+ capabilityGate: String,+ entryCount: Int,+ workCount: Int,+ checksum: String,+ payload: BackupV9Payload+ ) {+ 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: - V9 Payload++/// The ten arrays a 9/10 archive holds.+///+/// **Every record kind is enumerated whole**, and no parent record carries a+/// list of its children (Req 9.3). The 6/7 payload had a Site naming its rules,+/// a Work naming its Entries and a coverage table keyed by the record it+/// described; each of the three was a second spelling of a relationship the+/// child already states, and each could disagree with it. Memberships and+/// distinct pairs arrive under the same rule the characters do (Q17): a row+/// whose Work has not arrived exports naming the Work it belongs to, and imports+/// unattached (Req 9.5, Q22).+public struct BackupV9Payload: Codable, Equatable, Sendable {+ public let entries: [BackupV9Entry]+ public let works: [BackupV9Work]+ public let sites: [BackupV9Site]+ public let titlePatterns: [BackupV9TitlePattern]+ public let urlRules: [BackupV9URLRule]+ public let workTypes: [BackupV9WorkType]+ /// One row per Work and hostname (Req 9.1). The Work's site presence lives+ /// here and nowhere else.+ public let memberships: [BackupV9Membership]+ /// The reader's "not the same work" over an unordered pair (Req 5.5).+ public let distinctPairs: [BackupV9DistinctPair]+ public let characters: [BackupV9Character]+ public let suppressions: [BackupV9Suppression]++ public init(+ entries: [BackupV9Entry],+ works: [BackupV9Work],+ sites: [BackupV9Site],+ titlePatterns: [BackupV9TitlePattern],+ urlRules: [BackupV9URLRule],+ workTypes: [BackupV9WorkType] = [],+ memberships: [BackupV9Membership] = [],+ distinctPairs: [BackupV9DistinctPair] = [],+ characters: [BackupV9Character] = [],+ suppressions: [BackupV9Suppression] = []+ ) {+ self.entries = entries+ self.works = works+ self.sites = sites+ self.titlePatterns = titlePatterns+ self.urlRules = urlRules+ self.workTypes = workTypes+ self.memberships = memberships+ self.distinctPairs = distinctPairs+ self.characters = characters+ self.suppressions = suppressions+ }+}++// MARK: - V9 Records++/// One Site. **No child lists** (Req 9.3): a title rule and a URL rule each name+/// their hostname, so the Site naming them back was a second spelling of one+/// relationship — and the one the reference checks had to cross-examine.+public struct BackupV9Site: Codable, Equatable, Sendable {+ public let hostname: String+ public let displayName: String+ public let mode: SiteMode+ public let junkSuffixRule: JunkSuffixRule?++ public init(+ hostname: String,+ displayName: String,+ mode: SiteMode,+ junkSuffixRule: JunkSuffixRule?+ ) {+ self.hostname = hostname+ self.displayName = displayName+ self.mode = mode+ self.junkSuffixRule = junkSuffixRule+ }+}++/// One title rule. The arm and both trims travel as one `StoredPatternDefinition`+/// — the value V8 stores in `TitlePattern.definitionData` (Q25) — rather than as+/// a definition beside two loose trim columns.+public struct BackupV9TitlePattern: Codable, Equatable, Sendable {+ public let id: UUID+ public let siteHostname: String+ public let version: Int+ public let isActive: Bool+ public let createdAt: Date+ public let definition: StoredPatternDefinition++ public init(+ id: UUID,+ siteHostname: String,+ version: Int,+ isActive: Bool,+ createdAt: Date,+ definition: StoredPatternDefinition+ ) {+ self.id = id+ self.siteHostname = siteHostname+ self.version = version+ self.isActive = isActive+ self.createdAt = createdAt+ self.definition = definition+ }+}++/// One URL rule, unchanged from the generation that froze it: it never carried a+/// child list and its definition was already one value.+public struct BackupV9URLRule: 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+ }+}++/// One entry of the configured type list, as the exporter's fold produced it.+///+/// **One record per identity** (`configurable-work-types` Q32). Duplicate rows of+/// one UUID are a normal permanent state in the live store, so the exporter+/// writes the directory's folded identity rather than the rows, and `modifiedAt`+/// on the wire is the fold's max.+public struct BackupV9WorkType: Codable, Equatable, Sendable {+ public let id: UUID+ public let name: String+ /// `active` / `removed` / `merged`, carried raw so an archive written by a+ /// later build's wider state set decodes here rather than refusing.+ public let stateRaw: String+ public let canonicalID: UUID?+ public let createdAt: Date+ public let modifiedAt: Date++ public init(+ id: UUID,+ name: String,+ stateRaw: String,+ canonicalID: UUID?,+ createdAt: Date,+ modifiedAt: Date+ ) {+ self.id = id+ self.name = name+ self.stateRaw = stateRaw+ self.canonicalID = canonicalID+ self.createdAt = createdAt+ self.modifiedAt = modifiedAt+ }+}++/// One Work, with **no site of its own** (Req 9.1).+///+/// The hostname, URL identity, identity state, cited rule and confirmed Work URL+/// the 6/7 record carried are the membership's now, one row per site. `entryIDs`+/// is gone with the other child lists (Req 9.3), and `legacyType` with the+/// column only it carried — a Work typed by a legacy `typeRaw` is untyped from+/// V8 on (Req 10.3, Q16).+///+/// `genericNotesExtractionFingerprint` rides here rather than in a coverage+/// table (Req 9.4): it describes this record's own `genericNotes`, and a+/// separate table keyed by Work id was a second place for the same fact.+///+/// **9/10 adds the three status fields** (`work-and-reading-status` Req 8.1):+/// the two statuses travel as the typed enums, exactly as `titleProvenance`+/// does, and the verdict as the reader's text. All three are **required** —+/// there is no `decodeIfPresent` and no default (Q34). The only archive this+/// build accepts is one it wrote, so a record missing them is malformed, and a+/// default on decode would restore a reader's abandoned work as one they are+/// still reading rather than saying so.+public struct BackupV9Work: Codable, Equatable, Sendable {+ public let id: UUID+ public let displayTitle: String+ public let lastParsedTitle: String?+ public let genericNotes: String+ public let genreTags: [String]+ public let titleProvenance: TitleProvenance+ /// Whether the work itself is still being published (Req 1.1).+ public let workStatus: WorkStatus+ /// Where the reader is with it (Req 2.1).+ public let readingStatus: ReadingStatus+ /// The reader's own line about it, trimmed on write and carried verbatim+ /// here — empty when they have not written one.+ public let verdict: String+ /// Cites a `BackupV9WorkType`, or an entry this archive could not carry — a+ /// dangling id exports verbatim and imports as unresolved rather than+ /// refusing or inventing a name (`configurable-work-types` Q24).+ public let workTypeID: UUID?+ /// The resolved display name at export time, nil when the Work is untyped or+ /// its type is unresolved. It is what lets a *cross-library* import display+ /// and re-mint a type whose entry the archive could not carry.+ public let typeName: String?+ public let createdAt: Date+ public let modifiedAt: Date+ /// The fingerprint of the `genericNotes` text a character-extraction pass+ /// last covered, or nil.+ public let genericNotesExtractionFingerprint: String?++ public init(+ id: UUID,+ displayTitle: String,+ lastParsedTitle: String?,+ genericNotes: String,+ genreTags: [String],+ titleProvenance: TitleProvenance,+ workStatus: WorkStatus,+ readingStatus: ReadingStatus,+ verdict: String,+ workTypeID: UUID?,+ typeName: String?,+ createdAt: Date,+ modifiedAt: Date,+ genericNotesExtractionFingerprint: String? = nil+ ) {+ self.id = id+ self.displayTitle = displayTitle+ self.lastParsedTitle = lastParsedTitle+ self.genericNotes = genericNotes+ self.genreTags = genreTags+ self.titleProvenance = titleProvenance+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict+ self.workTypeID = workTypeID+ self.typeName = typeName+ self.createdAt = createdAt+ self.modifiedAt = modifiedAt+ self.genericNotesExtractionFingerprint = genericNotesExtractionFingerprint+ }++ /// The record's type, as the one assignment enum every subsystem takes.+ public var assignment: WorkTypeAssignment {+ workTypeID.map(WorkTypeAssignment.configured) ?? .none+ }+}++/// One Work's presence on one site (Req 9.1), a top-level record naming its Work+/// the way a character does (Q17).+///+/// `workID` is written from the row's own column rather than from its+/// relationship (Q37), so a membership whose Work has not arrived still exports+/// the Work it belongs to and re-attaches when that Work appears. It is optional+/// only because the column is; a row carrying neither is inert.+///+/// The cited rule is a **bare UUID**: a membership names the rule row and+/// carries no rule version (Req 10.4, Q28).+public struct BackupV9Membership: Codable, Equatable, Sendable {+ public let id: UUID+ public let workID: UUID?+ public let hostname: String+ public let createdAt: Date+ public let urlIdentity: String?+ public let urlIdentityState: WorkURLIdentityState+ public let urlIdentityRuleID: UUID?+ public let workURLString: String?++ public init(+ id: UUID,+ workID: UUID?,+ hostname: String,+ createdAt: Date,+ urlIdentity: String?,+ urlIdentityState: WorkURLIdentityState,+ urlIdentityRuleID: UUID?,+ workURLString: String?+ ) {+ self.id = id+ self.workID = workID+ self.hostname = hostname+ self.createdAt = createdAt+ self.urlIdentity = urlIdentity+ self.urlIdentityState = urlIdentityState+ self.urlIdentityRuleID = urlIdentityRuleID+ self.workURLString = workURLString+ }+}++/// One dismissed cross-site duplicate candidate (Req 5.5, 9.1).+///+/// Two UUIDs, sorted, exactly as the store holds them (Q21, Q27): the pair is+/// unordered, so it has one spelling, and it names Works the archive may not+/// carry — a pair whose Works have not arrived imports verbatim and is tolerated+/// (Req 8.3).+public struct BackupV9DistinctPair: Codable, Equatable, Sendable {+ public let id: UUID+ public let lowerWorkID: UUID+ public let higherWorkID: UUID+ public let recordedAt: Date++ public init(id: UUID, lowerWorkID: UUID, higherWorkID: UUID, recordedAt: Date) {+ self.id = id+ self.lowerWorkID = lowerWorkID+ self.higherWorkID = higherWorkID+ self.recordedAt = recordedAt+ }++ /// The record with its two ids in the canonical order, whatever order they+ /// arrived in. An unsorted pair is a second spelling of one dismissal, and+ /// `MembershipReconciler.dedupePairs` groups on the sorted form — so a+ /// hand-built or older archive's row is normalised on the way in rather than+ /// left as a duplicate nothing would ever match (task 20/21 review).+ public var sorted: BackupV9DistinctPair {+ let ids = WorkDistinctPair.sortedIDs(lowerWorkID, higherWorkID)+ guard ids.lower != lowerWorkID || ids.higher != higherWorkID else { return self }+ return BackupV9DistinctPair(+ id: id, lowerWorkID: ids.lower, higherWorkID: ids.higher, recordedAt: recordedAt)+ }+}++/// One Entry.+///+/// The seventeen citation columns and `identityKeyVersion` are one+/// `EntryCitations` value here (Q25, Q26), exactly as the store holds them: the+/// identity *basis version* is a case rather than an integer, and every citation+/// is one `CitedRule?`.+///+/// **This is what 8/9 changed, and 9/10 keeps.** A `CitedRule` is the rule's+/// UUID and nothing else (T-2281, Req 1.1 of `rule-citation-by-uuid`), so no+/// citation on the wire carries a `version` — and because the codec's checksum+/// is taken over the payload bytes and compared against a re-encoding, a file+/// that does carry one is refused.+///+/// `characterExtractionFingerprint` rides on the record whose `note` it+/// describes (Req 9.4), for the reason the Work's does.+public struct BackupV9Entry: 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+ /// The v1 key alias, always the raw URL (`m4` Q21). Retained after an S-rule+ /// rewrites `entryIdentityKey` so a title-less same-URL re-share still+ /// matches.+ public let conservativeIdentityKey: String+ public let identityBasis: EntryIdentityBasis+ public let urlWorkIdentity: String?+ public let chapterSequence: String?+ public let chapterTitle: String?+ 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 intentionallyUnattached: Bool+ public let citations: EntryCitations+ public let characterExtractionFingerprint: String?++ public init(+ id: UUID,+ captureTitle: String,+ captureTitleSource: CaptureTitleSource,+ rawURL: String,+ canonicalURL: String?,+ hostname: String,+ entryIdentityKey: String,+ conservativeIdentityKey: String,+ identityBasis: EntryIdentityBasis,+ urlWorkIdentity: String?,+ chapterSequence: String?,+ chapterTitle: String?,+ note: String,+ rating: Rating?,+ firstCapturedAt: Date,+ lastSharedAt: Date,+ modifiedAt: Date,+ workID: UUID?,+ intentionallyUnattached: Bool,+ citations: EntryCitations,+ characterExtractionFingerprint: String? = nil+ ) {+ self.id = id+ self.captureTitle = captureTitle+ self.captureTitleSource = captureTitleSource+ self.rawURL = rawURL+ self.canonicalURL = canonicalURL+ self.hostname = hostname+ self.entryIdentityKey = entryIdentityKey+ self.conservativeIdentityKey = conservativeIdentityKey+ self.identityBasis = identityBasis+ self.urlWorkIdentity = urlWorkIdentity+ self.chapterSequence = chapterSequence+ self.chapterTitle = chapterTitle+ self.note = note+ self.rating = rating+ self.firstCapturedAt = firstCapturedAt+ self.lastSharedAt = lastSharedAt+ self.modifiedAt = modifiedAt+ self.workID = workID+ self.intentionallyUnattached = intentionallyUnattached+ self.citations = citations+ self.characterExtractionFingerprint = characterExtractionFingerprint+ }+}++/// One character, as the archive holds it.+///+/// `facts` carries `CharacterFact` itself rather than a wire clone of it: the+/// fact *is* a value type with a stable Codable shape, and a second spelling+/// would be two definitions of one thing with no way to notice them drifting.+///+/// `workID` is optional and checked only when present — a character whose work+/// has not arrived is a tolerated in-flight state+/// (`character-extraction` Q78), a reference to a work the archive does not+/// carry is a file contradicting itself.+public struct BackupV9Character: Codable, Equatable, Sendable {+ public let id: UUID+ public let workID: UUID?+ public let name: String+ /// The retained key, minted at accept or creation and never re-derived from+ /// a rename, so it travels rather than being recomputed on the way in.+ public let nameKey: String+ public let aliases: [String]+ public let note: String+ public let facts: [CharacterFact]+ public let createdAt: Date+ public let modifiedAt: Date++ public init(+ id: UUID,+ workID: UUID?,+ name: String,+ nameKey: String,+ aliases: [String],+ note: String,+ facts: [CharacterFact],+ createdAt: Date,+ modifiedAt: Date+ ) {+ self.id = id+ self.workID = workID+ self.name = name+ self.nameKey = nameKey+ self.aliases = aliases+ self.note = note+ self.facts = facts+ self.createdAt = createdAt+ self.modifiedAt = modifiedAt+ }+}++/// One suppression row.+///+/// The enum columns travel **raw**, for the reason `BackupV9WorkType`'s+/// `stateRaw` does: an archive written by a later build's wider set decodes here+/// rather than refusing, and the store's own coercion answers for a value this+/// build cannot name.+public struct BackupV9Suppression: Codable, Equatable, Sendable {+ public let id: UUID+ public let workID: UUID?+ public let kindRaw: String+ public let nameKey: String+ /// Present on fact rows only. Explicit rather than inferred from a nil+ /// `sourceEntryID`, so a malformed row is distinguishable from a+ /// generic-notes citation.+ public let sourceKindRaw: String?+ public let sourceEntryID: UUID?+ public let evidence: String?+ public let statusRaw: String+ /// When the reader acted — the comparable the import value-guards with.+ public let actionAt: Date++ public init(+ id: UUID,+ workID: UUID?,+ kindRaw: String,+ nameKey: String,+ sourceKindRaw: String?,+ sourceEntryID: UUID?,+ evidence: String?,+ statusRaw: String,+ actionAt: Date+ ) {+ self.id = id+ self.workID = workID+ self.kindRaw = kindRaw+ self.nameKey = nameKey+ self.sourceKindRaw = sourceKindRaw+ self.sourceEntryID = sourceEntryID+ self.evidence = evidence+ self.statusRaw = statusRaw+ self.actionAt = actionAt+ }++ public var kind: CharacterSuppressionKind {+ ToleratedEnum.read(kindRaw, default: .candidate)+ }++ public var status: CharacterSuppressionStatus {+ ToleratedEnum.read(statusRaw, default: .active)+ }++ public var source: SourceRef? {+ SourceRef(kindRaw: sourceKindRaw, entryID: sourceEntryID)+ }+}
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swiftindex 30efeaf..4ddc488 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift@@ -220,7 +220,7 @@ public enum CharacterCitationRepointing { /// row's `modifiedAt` backwards**. The reconciler derives it from the /// collapsing Entries rather than a clock (Q56), so it can easily be older /// than the character it rewrites — and `CharacterGroup.modifiedAt` is what- /// `BackupV8Character` carries as its import value guard, so a backwards+ /// `BackupV9Character` carries as its import value guard, so a backwards /// stamp would let an older archive overwrite a newer character. @discardableResult public static func repoint(
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swiftindex eedf71e..b8e72f5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift@@ -12,19 +12,6 @@ public enum Rating: String, CaseIterable, Codable, Sendable { case down } -/// The **closed pre-feature** set of work-type values, and nothing more-/// (Decision 4). Since `configurable-work-types` it is no longer the app's type-/// vocabulary — it is the set of raw values a pre-feature build can have written-/// into `Work.typeRaw`, used to tell a legacy value from an unrecognised one and-/// to spell the untyped value `other`. It is deliberately not `CaseIterable`-driven-/// anywhere in the UI any more.-public enum WorkType: String, CaseIterable, Codable, Sendable {- case novel- case toon- case article- case other-}- /// The lifecycle of a `WorkTypeEntity`. `removed` hides an entry from the picker /// without touching the works that carry it (Decision 2); `merged` marks an /// entry convergence redirected to another identity (Decision 6), which is@@ -40,6 +27,43 @@ public enum TitleProvenance: String, CaseIterable, Codable, Sendable { case manual } +/// The **author's** side of a work: whether it is still being written.+///+/// Reader-entered and never inferred — nothing is read from a work's page and a+/// capture never changes it (Q3, Q8 of `work-and-reading-status`). There is+/// deliberately no `abandoned` value: a reader usually cannot tell an abandoned+/// work from a finished one, and `hiatus` covers the temporary case (Decision 4+/// of that spec).+///+/// The case order is the fixed order Req 6.1 gives the filter menu, and the raw+/// values are `Work.workStatusRaw`'s stored spellings — frozen once a snapshot+/// carries the column's default.+public enum WorkStatus: String, CaseIterable, Codable, Sendable {+ case ongoing+ case finished+ case hiatus+}++/// The **reader's** side of a work: where they stand with it.+///+/// `finished` is only valid on a `finished` work (Decision 1 of+/// `work-and-reading-status`), but that is an edit-mode rule rather than a data+/// guarantee: two devices can each make a valid edit that together violate it,+/// so a stored violating pair is displayed as stored (Req 3.5).+///+/// Same freezing rules as `WorkStatus`: the order is Req 6.1's, the raw values+/// are `Work.readingStatusRaw`'s stored spellings.+public enum ReadingStatus: String, CaseIterable, Codable, Sendable {+ case reading+ case finished+ case abandoned++ /// "Done reading" — the one derived predicate over this enum. Everything+ /// that shows or hides the verdict reads it, so the two-case test is+ /// spelled once rather than at every surface.+ public var isDone: Bool { self == .finished || self == .abandoned }+}+ public enum FieldProvenanceKind: String, CaseIterable, Codable, Sendable { case none case pattern
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 2342a1b..ef6d897 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -1241,6 +1241,12 @@ enum DuplicateReconciler { // could not place. Classified **once** for the whole fan-out: it is a // property of the carrier, and re-parsing every carrier URL per row made // a torn group of n rows pay n times for one answer.+ // The three status fields are read once for the same reason: each is a+ // tolerant read over a raw column, and the per-row guards below would+ // otherwise parse the carrier's raw twice per row.+ let carriedWorkStatus = carrier.workStatus+ let carriedReadingStatus = carrier.readingStatus+ let carriedVerdict = carrier.verdict let carriedURLs: [(hostname: String, url: String)] = carrier.membershipValues .compactMap { membership in guard let url = membership.workURLString,@@ -1281,6 +1287,30 @@ enum DuplicateReconciler { row.genreTags = carrier.genreTags changed = true }+ // Q14, on the `genreTags` shape above: a value the reader **set**+ // propagates, a default writes nothing. A carrier on `reading` must+ // never overwrite a sibling's `abandoned` — the scan and the write+ // read the rows at two different moments, so the guard is what makes+ // the direction safe rather than the classification.+ //+ // There is no `propagates` gate here: that one exists because a type+ // may be `.removed`, a state with no counterpart in a closed+ // three-value vocabulary.+ if carriedWorkStatus != .ongoing, row.workStatus != carriedWorkStatus {+ row.workStatus = carriedWorkStatus+ changed = true+ }+ if carriedReadingStatus != .reading, row.readingStatus != carriedReadingStatus {+ row.readingStatus = carriedReadingStatus+ changed = true+ }+ // Blank means absent here as it does in the fold and the sheet+ // (`M2Unicode.isBlank`), so a whitespace-only verdict never+ // propagates as if it were authored.+ if !M2Unicode.isBlank(carriedVerdict), row.verdict != carriedVerdict {+ row.verdict = carriedVerdict+ changed = true+ } // Req 8.4's gate. A **legacy** carrier propagates exactly as it did // before this feature; a configured one propagates only while its // entry is active. A removed, unresolved or unrecognised carrier
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swiftindex 4edbfa7..868ba8f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift@@ -25,6 +25,12 @@ public enum DuplicateResolutionField: String, Sendable, Equatable, CaseIterable case workURL case genreTags case type+ /// V10 (Req 7.2): a status the reader moved off its default and a verdict+ /// they typed are authored, so two copies disagreeing on one are a decision+ /// the sheet has to name.+ case workStatus+ case readingStatus+ case verdict // Character case name case aliases@@ -76,11 +82,18 @@ public struct WorkVariantChoice: Sendable, Equatable, Identifiable { /// The variant's type, with the name to show for it: the sheet is asking /// the reader to choose between labels, and an identity is not a label. public let typeDisplay: WorkTypeDisplay+ /// V10: what the copy says about the work and about the reader (Req 7.2).+ public let workStatus: WorkStatus+ public let readingStatus: ReadingStatus+ /// Shown whatever the reading status — the one place Q7's hidden verdict is+ /// visible, because Q21 makes it the only thing telling two copies apart.+ public let verdict: String public let firstCapturedAt: Date public init( id: VariantID, displayTitle: String, manualTitle: String?, genericNotes: String, workURLString: String?, genreTags: [String], typeDisplay: WorkTypeDisplay,+ workStatus: WorkStatus, readingStatus: ReadingStatus, verdict: String, firstCapturedAt: Date ) { self.id = id@@ -90,6 +103,9 @@ public struct WorkVariantChoice: Sendable, Equatable, Identifiable { self.workURLString = workURLString self.genreTags = genreTags self.typeDisplay = typeDisplay+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict self.firstCapturedAt = firstCapturedAt } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swiftindex 30d7a06..1e08102 100644--- a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift@@ -191,7 +191,7 @@ extension Entry { /// One citation row, as every pass that walks them sees it. /// /// It used to be a table of key paths into the model and into- /// `BackupV8Entry`, hand-enumerated nowhere else. With the citations folded+ /// `BackupV9Entry`, hand-enumerated nowhere else. With the citations folded /// into one blob the key paths have nothing to point at, so the row is a /// *value* now — but the seven rows, their order and their labels are /// unchanged, because `citerHostnames` still keeps the first hostname it
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftindex 2d087b1..2775028 100644--- a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -237,19 +237,33 @@ public struct WorkAuthoredContent: AuthoredContent { /// which is the pre-feature `type == .other ? nil : type` rule stated over /// the new representation. public var typeAssignment: WorkTypeAssignment+ /// V10, Q14: a status the reader moved off its default is authored, exactly+ /// as a non-`.none` type is. A collapse can never drop it silently; the cost+ /// is an occasional review card.+ public var workStatus: WorkStatus+ public var readingStatus: ReadingStatus+ /// Non-empty is authored — including under a `reading` status, where Q7+ /// hides the text but Q14 still counts it (Q21).+ public var verdict: String public init( genericNotes: String = "", manualTitle: String? = nil, workURLString: String? = nil, genreTags: [String] = [],- typeAssignment: WorkTypeAssignment = .none+ typeAssignment: WorkTypeAssignment = .none,+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) { self.genericNotes = genericNotes self.manualTitle = manualTitle self.workURLString = workURLString self.genreTags = genreTags.sorted() self.typeAssignment = typeAssignment+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict } public static let bare = WorkAuthoredContent()@@ -257,6 +271,7 @@ public struct WorkAuthoredContent: AuthoredContent { public var isBare: Bool { genericNotes.isEmpty && manualTitle == nil && workURLString == nil && genreTags.isEmpty && typeAssignment == .none+ && workStatus == .ongoing && readingStatus == .reading && verdict.isEmpty } public var orderComponents: [OrderComponent] {@@ -268,6 +283,12 @@ public struct WorkAuthoredContent: AuthoredContent { // The identity, never the name: a rename must leave duplicate // grouping and variant selection exactly where they were (Req 4.2). .absentableString(typeAssignment.orderToken),+ // Req 7.2: two rows differing on one of these are two variants, so+ // each occupies a slot of its own. Q29 accepts that every existing+ // Work `VariantID` changes once as a result.+ .string(workStatus.rawValue),+ .string(readingStatus.rawValue),+ .string(verdict), ] } }@@ -543,7 +564,13 @@ public enum GroupOrdering { manualTitle: manualTitle(of: work), workURLString: primary?.workURLString, genreTags: work.genreTags,- typeAssignment: typeAssignment)+ typeAssignment: typeAssignment,+ // Through the tolerant accessors: an unknown stored spelling is a+ // library mid-rollout, not damage, and reads as its default here+ // exactly as it does everywhere else (Req 1.3, 2.7).+ workStatus: work.workStatus,+ readingStatus: work.readingStatus,+ verdict: work.verdict) } // MARK: Canonical definition serialisations (Q63)
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swiftindex 9ef8405..cce6ca4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift@@ -37,10 +37,13 @@ extension LibraryRepository { // built over a frozen snapshot wins SwiftData's name-keyed entity // registry for the rest of the process // (`docs/agent-notes/schema-migration.md`). While the snapshot only- // *added* columns that showed up as saves silently dropping them; now- // that V9 **removes**, it aborts the process instead, with- // `NSUnknownKeyException` on a column the live entity no longer has.- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ // *added*, that showed up as saves silently dropping a column; V9's+ // stage **removed**, which aborted the process instead with+ // `NSUnknownKeyException` on a column the live entity no longer had;+ // and V10 *adds* again, so the live shape is no longer even a subset of+ // the frozen one. Every one of those is a reason to name the live+ // schema here, and it must be re-checked on every snapshot freeze.+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true,
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex 4597ed6..e412273 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -4,37 +4,37 @@ import SwiftData /// Runtime opening of the live library, classified then acted on. ///-/// Every store the app can reach is recorded at V8 or above, and the one-/// conversion left is the `.lightweight` V8 → V9 stage `ModelContainer.init`+/// Every store the app can reach is recorded at V9 or above, and the one+/// conversion left is the `.lightweight` V9 → V10 stage `ModelContainer.init` /// runs: the sidecar, the V3 reader, the completion pass and every stage below-/// V8 are retired (Decision 1; Q2 of `drop-superseded-columns`). What survives-/// is the readiness contract. The app validates with-/// `LibraryValidator` and clears residual evidence; the marker it publishes-/// contains `"9"` (`extensionOpenableMarkerVersion`), the only version the-/// extension opens (Q14).+/// V9 are retired (Decision 1; Q2 of `drop-superseded-columns`, Q18 of+/// `work-and-reading-status`). What survives is the readiness contract. The app+/// validates with `LibraryValidator` and clears residual evidence; the marker it+/// publishes contains `"10"` (`extensionOpenableMarkerVersion`), the only+/// version the extension opens (Q14). /// /// An *empty* store is marked ready as soon as it exists, so the app either opens /// a ready library or throws — there is no third state for the reader to resolve.-/// It is marked at `"9"` directly: there is nothing in it to bring forward (Q26).+/// It is marked at `"10"` directly: there is nothing in it to bring forward (Q26). ///-/// **There is one lagging generation: `"8"`.** V9 drops the columns V8-/// superseded, and the whole of that is the lightweight stage `ModelContainer`-/// runs — so the `.markerLagging` arm opens, validates, and publishes `"9"`,-/// with no data pass and no reconciler (Q9). The digit exists even though the-/// stage needs no help from it, because it is what keeps the *extension* out of-/// a conversion that destroys the source (Q3): `openContainer` passes the-/// migration plan for both roles, and only the marker check stops a concurrent-/// share-sheet invocation from running the drop.+/// **There is one lagging generation: `"9"`.** V10 adds three defaulted `Work`+/// columns, and the whole of that is the lightweight stage `ModelContainer`+/// runs — so the `.markerLagging` arm opens, validates, and publishes `"10"`,+/// with no data pass and no reconciler. The digit exists even though the stage+/// needs no help from it, because it is what keeps the *extension* out of a+/// conversion it must never run (Q3): `openContainer` passes the migration plan+/// for both roles, and only the marker check stops a concurrent share-sheet+/// invocation from performing it. ///-/// `"7"` is **gone** rather than kept beside `"8"`: every device is confirmed at-/// `"8"` (Q2), and the substitution the schema-migration note allows only after+/// `"8"` is **gone** rather than kept beside `"9"`: every device is confirmed at+/// `"9"` (Q18), and the substitution the schema-migration note allows only after /// re-verifying the population is what that confirmation buys. /// /// A store found on any other digit is refused, naming it, and the recovery is /// the backup archive, exactly as for a store recorded below V5. The two roles /// differ in what they accept and in what they may do about it: the app opens-/// `"8"` and `"9"` (`appOpenableMarkerVersions`) and may create, convert and mark-/// a store; the extension opens `"9"` only and writes nothing.+/// `"9"` and `"10"` (`appOpenableMarkerVersions`) and may create, convert and+/// mark a store; the extension opens `"10"` only and writes nothing. public extension LibraryRepository { /// The result of evaluating the live library's fixed-path state under an /// exclusive lease.@@ -42,7 +42,7 @@ public extension LibraryRepository { case ready(LibraryRecordCounts) } - /// Extension-only readiness result. The extension opens only a `"9"` marker.+ /// Extension-only readiness result. The extension opens only a `"10"` marker. enum ExtensionResult: Equatable, Sendable { case ready(LibraryRecordCounts) }@@ -184,31 +184,31 @@ public extension LibraryRepository { return Certification(result: .ready(counts), diagnostics: diagnostics) case .markerLagging(let generation):- // open (which drops the superseded columns) → validate → publish- // `"9"`.+ // open (which adds the three defaulted status columns) → validate →+ // publish `"10"`. //- // **No data pass and no reconciler** (Q9). The `"7"` arm ran+ // **No data pass and no reconciler.** The `"7"` arm ran // `V8PopulationPass` and `MembershipReconciler` because V8 *added*- // tables and blobs that something had to fill; V9 only takes columns- // away, and the lightweight stage does the whole of it inside- // `ModelContainer.init`. The launch reconcile runs moments later on- // the same store for anything else.+ // tables and blobs that something had to fill; V10 adds columns+ // whose *defaults* fill them, and the lightweight stage does the+ // whole of it inside `ModelContainer.init`. The launch reconcile+ // runs moments later on the same store for anything else. // // **Validation is the gate, and the marker goes after it.** // There is no pass to certify itself with, so what certifies the- // conversion is the store validating: a throw here leaves `"8"` on+ // conversion is the store validating: a throw here leaves `"9"` on // disk, fails the open, and the next open re-enters this arm over a // store the stage has already converted — which is safe, because- // dropping columns that are already gone is a no-op.+ // adding columns that are already there is a no-op (Req 9.1). // // The residual evidence goes **last**, after the publish rather than // with the validation: a publish that fails on a full disk leaves- // the library at `"8"` with its historical marker and sidecar still+ // the library at `"9"` with its historical marker and sidecar still // beside it, which is the state the next open wants to find. let container = try openCertificationContainer(configuration, hooks: hooks) let context = ModelContext(container) bootstrapLogger.debug(- "Marker generation \(generation, privacy: .public) is lagging; certifying the V9 conversion")+ "Marker generation \(generation, privacy: .public) is lagging; certifying the V10 conversion") let diagnostics = try validateStore(context: context) try publishReadiness(at: configuration.readinessMarkerURL) clearResidualEvidence(configuration)@@ -385,18 +385,19 @@ extension LibraryRepository { var quarantined: [String: LibraryValidationError] { diagnostics.quarantineMap() } } - /// Opens the fixed-path store with the live V9 schema and- /// `AsterismV9MigrationPlan`, which declares `[V8, V9]` and one lightweight- /// stage: this call is where an installed V8 library is converted, and the- /// only place it happens.+ /// Opens the fixed-path store with the live V10 schema and+ /// `AsterismV10MigrationPlan`, which declares `[V9, V10]` and one+ /// lightweight stage: this call is where an installed V9 library is+ /// converted, and the only place it happens. ///- /// **The V8 → V9 stage removes** — 36 attributes and the `Work.site` ↔- /// `Site.works` inverse pair — which is why nothing may read any of them by- /// the time this runs. It needs no data pass afterwards: the rows and blobs- /// it leaves behind were filled by `V8PopulationPass` under the V8 build,- /// and every device is confirmed past that marker (Q2).+ /// **The V9 → V10 stage adds** — `Work.workStatusRaw`, `readingStatusRaw`+ /// and `verdict`, three defaulted scalars — so the conversion is the+ /// attribute defaults being written to every existing row, with no data pass+ /// behind it (Req 9.1). The V8 → V9 stage that removed 36 attributes and the+ /// `Work.site` ↔ `Site.works` inverse pair is retired with its snapshot+ /// (Q18): every device is confirmed at marker `"9"`. ///- /// A store recorded below V8 has no stage and is refused here — `classify`+ /// A store recorded below V9 has no stage and is refused here — `classify` /// already refuses one before any container is constructed (Req 2.9, /// Decision 1 of `retire-migration-chain`), so the refusal is a second /// closed door rather than a new one.@@ -411,13 +412,13 @@ extension LibraryRepository { at storeURL: URL, mirroring cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .none ) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let storeConfiguration = ModelConfiguration( // **Frozen persisted state (Req 3.5).** This names the store // configuration *inside* the container, not the file — `url:` below is- // the locator. It is frozen anyway (Q13): the store it labels holds V9,- // so the name is six versions behind, and renaming it buys nothing on- // a path that opens the owner's only library.+ // the locator. It is frozen anyway (Q13): the store it labels holds+ // V10, so the name is seven versions behind, and renaming it buys+ // nothing on a path that opens the owner's only library. "AsterismV3", schema: schema, url: storeURL,@@ -425,7 +426,7 @@ extension LibraryRepository { ) return try ModelContainer( for: schema,- migrationPlan: AsterismV9MigrationPlan.self,+ migrationPlan: AsterismV10MigrationPlan.self, configurations: [storeConfiguration] ) }@@ -434,7 +435,7 @@ extension LibraryRepository { /// either role opens, and the only version production publishes (Q32). /// /// Unversioned by name on purpose: it always writes the generation the build- /// certifies at, and the digit has moved three times already.+ /// certifies at, and the digit has moved six times already. public static func publishReadiness(at url: URL) throws { do { try Data("\(extensionOpenableMarkerVersion)\n".utf8).write(to: url, options: .atomic)@@ -544,25 +545,28 @@ extension LibraryRepository { /// /// `data-model-cleanups` Decision 2 had reduced this to one digit, on the /// argument that the population was one user whose every device carried- /// `"7"`. `multi-site-works` added `"7"` back beside `"8"`, and- /// `drop-superseded-columns` **substitutes** rather than adding: `"7"` goes- /// and `"8"` takes its place, because every device is confirmed at `"8"`- /// (Q2) and a lagging arm no device can reach is a path nothing tests. That- /// substitution is what `docs/agent-notes/schema-migration.md` allows only- /// after re-verifying the population, which Q2 records.+ /// `"7"`. `multi-site-works` added `"7"` back beside `"8"`, and every bump+ /// since has **substituted** rather than added: `drop-superseded-columns`+ /// put `"8"` in `"7"`'s place (Q2), and `work-and-reading-status` puts+ /// `"9"` in `"8"`'s (Q18), each time because every device is confirmed at+ /// the new digit and a lagging arm no device can reach is a path nothing+ /// tests. That substitution is what `docs/agent-notes/schema-migration.md`+ /// allows only after re-verifying the population. static let appOpenableMarkerVersions: Set<String> = [ laggingOpenableMarkerVersion, extensionOpenableMarkerVersion, ] /// The one lagging generation the app still opens: a library certified by a- /// V8 build, which `.markerLagging` converts and re-marks. Frozen persisted+ /// V9 build, which `.markerLagging` converts and re-marks. Frozen persisted /// state, like its successor below.- static let laggingOpenableMarkerVersion = "8"+ static let laggingOpenableMarkerVersion = "9" /// The only version the **extension** opens, and the one `publishReadiness` /// writes (Q14). Frozen persisted state — these are the bytes on disk in an- /// installed library (Req 3.5).- static let extensionOpenableMarkerVersion = "9"+ /// installed library (Req 3.5). **The first generation spelled with two+ /// characters**, which is why nothing anywhere may assume a marker is one+ /// character long.+ static let extensionOpenableMarkerVersion = "10" // The app-side counterpart of `validateMarkerContentForExtension` stood // here. It restated the acceptance test the classifier performs, and@@ -588,13 +592,11 @@ extension LibraryRepository { /// the fork back the moment it opened two. `multi-site-works` is that /// moment. ///- /// * A generation the app *does* open — `"8"`, the update window: the app is+ /// * A generation the app *does* open — `"9"`, the update window: the app is /// updated and not yet launched, the library still records the previous /// digit, and opening the app completes the conversion. The message says /// so, and `configurable-work-types` Req 8.7 requires the capture to fail- /// safely rather than convert a store under a shared lock — which matters- /// more at V9 than it did at V8, because the conversion *drops* columns- /// and a concurrent extension open mid-drop would be destructive (Q3).+ /// safely rather than convert a store under a shared lock (Req 9.3). /// * Anything else — a retired or unknown digit: nothing the reader can do /// from here, and the recovery is the backup archive. The wording is the /// extension's own — "the containing app has not initialized the current
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swiftindex ec482e7..0c6b440 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift@@ -16,7 +16,7 @@ enum BootstrapState: Equatable, Sendable { /// migration that would raise it is gone, and the recovery is the backup /// archive. ///- /// **The floor is V8, not V5** — the plan is `[V8, V9]` — so V5, V6 and V7+ /// **The floor is V9, not V5** — the plan is `[V9, V10]` — so V5 through V8 /// stores are equally beyond raising. The name is inherited from when V5 /// *was* the floor and is kept deliberately (Q35 of /// `drop-superseded-columns`); the refusal it stands for has widened under@@ -24,19 +24,19 @@ enum BootstrapState: Equatable, Sendable { /// `StoreMetadata` positively identifies only a below-V5 store, because /// `NSStoreModelVersionIdentifiers` is advisory and a reader that refused /// on anything it did not recognise would lock the owner's only library.- /// A V5–V7 store is therefore refused a row later, by its retired marker- /// digit (`"5"`, `"6"` and `"7"` all fall to `.unrecognised`), with the+ /// A V5–V8 store is therefore refused a row later, by its retired marker+ /// digit (`"5"` through `"8"` all fall to `.unrecognised`), with the /// same recovery. Both paths refuse; only this one names a store version. case belowV5(version: String) /// A certified library: the readiness marker records the current- /// generation, `"9"`, and a store is present.+ /// generation, `"10"`, and a store is present. case ready- /// A library certified at the **previous** generation, `"8"`, with a store- /// present: V9's schema stage drops the superseded columns on the way in,- /// which is the whole of the conversion, so all this arm owes is validating- /// it and republishing (Q9). The app does that; the extension refuses and- /// says to open the app (Req 2.3), which is what keeps the drop out of a- /// process that holds a shared lock (Q3).+ /// A library certified at the **previous** generation, `"9"`, with a store+ /// present: V10's schema stage adds the three defaulted status columns on+ /// the way in, which is the whole of the conversion, so all this arm owes is+ /// validating it and republishing. The app does that; the extension refuses+ /// and says to open the app (Req 9.3), which is what keeps the conversion+ /// out of a process that holds a shared lock (Q3). case markerLagging(generation: String) /// Evidence that a library existed, with no store file of any kind to go with /// it. Refused so the evidence survives for a restore (Req 2.6).@@ -82,13 +82,13 @@ extension LibraryRepository { /// logical write (Req 2.1, 2.8). /// /// **The order is the specification.** The predicates overlap — a stale- /// historical marker beside a valid `"7"` marker is a ready library with a+ /// historical marker beside a valid `"10"` marker is a ready library with a /// leftover, not an ambiguity — and the first match wins, which is what makes /// overlapping evidence resolvable at all (Q15). The rows, in order: /// /// 1. a positively below-V5 recorded version (Req 2.9)- /// 2. marker `"9"` and a store present (Req 2.2)- /// 3. marker `"8"` — a generation the app still opens — and a store present+ /// 2. marker `"10"` and a store present (Req 2.2)+ /// 3. marker `"9"` — a generation the app still opens — and a store present /// 4. any evidence with no store present (Req 2.6) /// 5. a store present with no readiness marker of any generation (Req 2.4) /// 6. nothing on disk (Req 2.5)@@ -96,10 +96,12 @@ extension LibraryRepository { /// (Req 2.7) /// /// **Row 3 holds one digit at a time.** `multi-site-works` brought it back- /// for `"7"`; `drop-superseded-columns` substitutes `"8"` for it rather than- /// adding a third, because every device is confirmed past `"7"` (Q2) and a- /// lagging arm no device can reach is a path nothing tests. Substituting is- /// what `docs/agent-notes/schema-migration.md` permits only after that+ /// for `"7"`; every bump since substitutes rather than adding a third —+ /// `"8"` for `"7"` (Q2 of `drop-superseded-columns`), `"9"` for `"8"`+ /// (Q18 of `work-and-reading-status`) — because every device is confirmed+ /// past the digit that goes, and a lagging arm no device can reach is a path+ /// nothing tests. Substituting is what+ /// `docs/agent-notes/schema-migration.md` permits only after that /// confirmation. A digit outside the set still falls to the last row and is /// refused naming itself, with the backup archive as the recovery. ///@@ -216,8 +218,9 @@ extension LibraryRepository { /// a corrupt or truncated write can make that arbitrarily long, and the /// reason string built from it reaches a `.public` os_log line and the /// reader's screen. Both are capped here rather than at either sink: a- /// generation digit is one character, so 32 is already far more than any- /// legitimate value, and anything longer is diagnosed by its prefix.+ /// generation is a short number — two characters since `"10"` — so 32 is+ /// already far more than any legitimate value, and anything longer is+ /// diagnosed by its prefix. private static func abbreviatedMarkerText(_ version: String) -> String { let limit = 32 guard version.count > limit else { return version }@@ -233,9 +236,9 @@ extension LibraryRepository { return reason case .version(let version): // The shipped wording of the app-side marker check, plus the digit- // itself: after Decision 2 this arm also catches the retired `"4"`,- // `"5"` and `"6"` generations, and a refusal that did not say which- // one it found would leave the reader with nothing to act on.+ // itself: this arm catches every retired generation, `"4"` through+ // `"8"`, and a refusal that did not say which one it found would+ // leave the reader with nothing to act on. return "marker declares an unsupported schema version " + "\"\(abbreviatedMarkerText(version))\"; restore from a backup archive" case nil:
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex 4b6e553..d8c0ed6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -17,13 +17,13 @@ public struct InterruptedImportReport: Codable, Sendable, Equatable { } } -/// The one field the off-host pre-pass rewrites. `BackupV8Membership`'s+/// The one field the off-host pre-pass rewrites. `BackupV9Membership`'s /// properties are `let`, so the value is rebuilt through the memberwise /// initialiser rather than mutated — which is what keeps the record a value the /// commit path can treat as verbatim.-extension BackupV8Membership {- fileprivate func withWorkURLString(_ value: String?) -> BackupV8Membership {- BackupV8Membership(+extension BackupV9Membership {+ fileprivate func withWorkURLString(_ value: String?) -> BackupV9Membership {+ BackupV9Membership( id: id, workID: workID, hostname: hostname, createdAt: createdAt, urlIdentity: urlIdentity, urlIdentityState: urlIdentityState, urlIdentityRuleID: urlIdentityRuleID, workURLString: value)@@ -195,7 +195,7 @@ extension LibraryRepository { // (Decision 10), because the archive's own rules are part of what makes // its designation legal: a `.taught` restore is legal precisely because // the active title rule arrives in this same step.- var matchedRecords: [BackupV8Site] = []+ var matchedRecords: [BackupV9Site] = [] for record in payload.sites { if sitesByHostname[record.hostname] != nil { matchedRecords.append(record)@@ -435,7 +435,7 @@ extension LibraryRepository { /// Every write is guarded by a comparison, so re-importing the same archive /// dirties nothing — the same property `SiteReconciler.applyUnion` keeps. internal static func applyDesignation(- _ record: BackupV8Site,+ _ record: BackupV9Site, to site: Site, patterns: [TitlePattern], urlRules: [URLRulePattern]@@ -484,7 +484,7 @@ extension LibraryRepository { /// Work's site presence either, and a group skipped as torn must not be /// half-updated through its memberships. private static func commitWorks(- _ records: [BackupV8Work],+ _ records: [BackupV9Work], into workRows: inout [UUID: [Work]], types: WorkTypeDirectory, context: ModelContext,@@ -557,7 +557,7 @@ extension LibraryRepository { /// Inserting stays unconditional: a row the library does not hold cannot be /// regressed, and the upsert's posture is to add (Req 4.1). private static func commitMemberships(- _ records: [BackupV8Membership],+ _ records: [BackupV9Membership], workRows: [UUID: [Work]], workTargets: [UUID: Work], appliedWorkIDs: Set<UUID>,@@ -664,10 +664,10 @@ extension LibraryRepository { /// [1.7]: ../../../../specs/wrong-host-work-url-heal/requirements.md#17 /// [1.8]: ../../../../specs/wrong-host-work-url-heal/requirements.md#18 internal static func normalizedMemberships(- _ records: [BackupV8Membership],+ _ records: [BackupV9Membership], existingMembershipIDs: Set<UUID>, appliedWorkIDs: Set<UUID>- ) -> [BackupV8Membership] {+ ) -> [BackupV9Membership] { var indicesByWork: [UUID: [Int]] = [:] for (index, record) in records.enumerated() { guard let workID = record.workID else { continue }@@ -730,7 +730,7 @@ extension LibraryRepository { /// library — the insert branch, which is unconditional, or the update /// branch, which is gated on the Work's record having been applied. private static func willWriteMembership(- _ record: BackupV8Membership,+ _ record: BackupV9Membership, existingMembershipIDs: Set<UUID>, appliedWorkIDs: Set<UUID> ) -> Bool {@@ -744,7 +744,7 @@ extension LibraryRepository { /// reconciler's latest-wins rule reads — so an older archive cannot undo a /// newer dismissal, and re-importing the same archive writes nothing. private static func commitDistinctPairs(- _ records: [BackupV8DistinctPair],+ _ records: [BackupV9DistinctPair], context: ModelContext, batchSize: Int, saveStrategy: any RepositorySaveStrategy@@ -775,12 +775,18 @@ extension LibraryRepository { /// The mutable half of an archive Work record, shared by the upsert and by /// the materializers so an inserted record and an updated one cannot drift /// apart. Identity, hostname, and `createdAt` are set at construction.- internal static func apply(_ record: BackupV8Work, to work: Work) {+ internal static func apply(_ record: BackupV9Work, to work: Work) { work.displayTitle = record.displayTitle work.lastParsedTitle = record.lastParsedTitle work.genericNotes = record.genericNotes work.genreTags = record.genreTags work.titleProvenanceRaw = record.titleProvenance.rawValue+ // Req 8.1 and 2.6: the record's three, written raw like the provenance+ // beside them. A work materialised from an archive carries what the+ // file says; the column defaults only stand where no record reaches it.+ work.workStatusRaw = record.workStatus.rawValue+ work.readingStatusRaw = record.readingStatus.rawValue+ work.verdict = record.verdict work.createdAt = record.createdAt work.modifiedAt = record.modifiedAt WorkTypeWriter.apply(record.assignment, to: work)@@ -789,7 +795,7 @@ extension LibraryRepository { /// The mutable half of an archive membership record. Identity, hostname and /// `createdAt` are set at construction, so what an update moves is the /// site-specific content: the URL identity triple and the confirmed Work URL.- internal static func apply(_ record: BackupV8Membership, to membership: WorkSiteMembership) {+ internal static func apply(_ record: BackupV9Membership, to membership: WorkSiteMembership) { membership.hostname = record.hostname membership.createdAt = record.createdAt membership.urlIdentity = record.urlIdentity@@ -799,7 +805,7 @@ extension LibraryRepository { membership.workID = record.workID ?? membership.workID } - internal static func apply(_ record: BackupV8Entry, to entry: Entry) {+ internal static func apply(_ record: BackupV9Entry, to entry: Entry) { entry.captureTitle = record.captureTitle entry.captureTitleSourceRaw = record.captureTitleSource.rawValue entry.rawURLString = record.rawURL
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftindex 5832f5f..ad143b6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -351,6 +351,15 @@ extension LibraryRepository { workURLString: variant.content.workURLString, genreTags: carrier?.genreTags ?? variant.content.genreTags, typeDisplay: types.display(of: assignment),+ // V10: the carrier row's stored values. Unlike the tags and the+ // display title, these three cannot *differ* from the variant's —+ // `GroupOrdering.authoredContent(of:)` copies them off the row+ // verbatim — so this is not a preference between two answers. The+ // fallback covers the one case where there is no answer: no carrier+ // row was found, because the row has gone.+ workStatus: carrier?.workStatus ?? variant.content.workStatus,+ readingStatus: carrier?.readingStatus ?? variant.content.readingStatus,+ verdict: carrier?.verdict ?? variant.content.verdict, firstCapturedAt: variant.firstCapturedAt) } @@ -420,6 +429,11 @@ extension LibraryRepository { if Set(contents.map { $0.typeAssignment.orderToken ?? "" }).count > 1 { fields.append(.type) }+ // V10 (Req 7.2). No sentinel: the values are non-optional, so the set of+ // what the variants hold is the whole comparison.+ if Set(contents.map(\.workStatus)).count > 1 { fields.append(.workStatus) }+ if Set(contents.map(\.readingStatus)).count > 1 { fields.append(.readingStatus) }+ if Set(contents.map(\.verdict)).count > 1 { fields.append(.verdict) } return fields } @@ -635,14 +649,20 @@ extension LibraryRepository { genreTags: row?.genreTags ?? variant.content.genreTags, typeDisplay: types.display( of: row.map(WorkTypeAssignment.assignment(of:))- ?? variant.content.typeAssignment))+ ?? variant.content.typeAssignment),+ workStatus: row?.workStatus ?? variant.content.workStatus,+ readingStatus: row?.readingStatus ?? variant.content.readingStatus,+ verdict: row?.verdict ?? variant.content.verdict) } let chosenSide = WorkVariantSide( displayTitle: carrier.displayTitle, titleProvenance: carrier.titleProvenance, workURLsByHostname: Self.workURLsByHostname(of: carrier), genericNotes: carrier.genericNotes, genreTags: carrier.genreTags,- typeDisplay: types.display(of: WorkTypeAssignment.assignment(of: carrier)))+ typeDisplay: types.display(of: WorkTypeAssignment.assignment(of: carrier)),+ workStatus: carrier.workStatus,+ readingStatus: carrier.readingStatus,+ verdict: carrier.verdict) let union = WorkVariantUnion.fold(into: chosenSide, others: others) let timestamp = MillisecondInstant.quantize(clock.now())@@ -653,6 +673,12 @@ extension LibraryRepository { row.titleProvenance = carrier.titleProvenance row.genericNotes = union.genericNotes row.genreTags = union.genreTags+ // Req 7.2: the chosen variant's statuses and verdict, carrier-wins+ // like the title and the type. The fold never moves them; a losing+ // verdict travels in the notes it just wrote (Q41).+ row.workStatus = carrier.workStatus+ row.readingStatus = carrier.readingStatus+ row.verdict = carrier.verdict // V8: the confirmed Work URL lives on the site membership (Req 3.6), // so each site's answer lands on that site's membership. A hostname // the fold produced no URL for is left alone rather than cleared —
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swiftindex 524d2d4..545cd02 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift@@ -394,7 +394,9 @@ extension LibraryRepository { genericNotes: base.genericNotes, typeDisplay: base.typeDisplay, genreTags: base.genreTags, titleProvenance: base.titleProvenance, createdAt: base.createdAt,- modifiedAt: base.modifiedAt, entries: entries, groupState: group.state)+ modifiedAt: base.modifiedAt, entries: entries, groupState: group.state,+ workStatus: base.workStatus, readingStatus: base.readingStatus,+ verdict: base.verdict) } // The carrier's assignment, like every other authored field of a split // group: the row holding the content the group presents is the row whose@@ -422,6 +424,12 @@ extension LibraryRepository { createdAt: group.createdAt, modifiedAt: group.modifiedAt, entries: entries,- groupState: group.state)+ groupState: group.state,+ // The carrier's, like every other authored field of a split group+ // (Req 7.2): the row holding the content the group presents is the+ // row whose statuses and verdict it presents.+ workStatus: carried.workStatus,+ readingStatus: carried.readingStatus,+ verdict: carried.verdict) } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swiftindex dfb9326..0adce24 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift@@ -315,10 +315,18 @@ extension WorkEditBasis { /// a canonicalized assignment because `authoredContent` put one there, and a /// basis compared raw would refuse a redirect purely because a same-name /// merge landed between the read and the write (Req 6.2).+ ///+ /// Req 7.4: `updateWork` writes the two statuses and the verdict to every+ /// row as well, so a survivor differing on one of them is diverged for the+ /// same reason a differing manual title is — plain `==`, since the values+ /// are non-optional and need no canonicalisation. func matches(_ content: WorkAuthoredContent, types: WorkTypeDirectory) -> Bool { content.genericNotes == genericNotes && content.manualTitle == manualTitle && content.genreTags == genreTags.sorted() && content.typeAssignment == types.canonicalized(typeAssignment)+ && content.workStatus == workStatus+ && content.readingStatus == readingStatus+ && content.verdict == verdict } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 20cc7f8..1be2d31 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -1209,6 +1209,7 @@ public actor LibraryRepository { throw LibraryRepositoryError.invalidInput(operation: "updating Work", reason: "display title is blank") } let normalizedTags = Self.normalizeTags(draft.genreTags)+ let normalizedVerdict = Self.normalizeVerdict(draft.verdict) return try await withLockedContext(mode: .exclusive, operation: "updating Work") { context in let group: WorkGroup switch try self.resolveWorkWriteTarget(id: id, basis: basis, context: context) {@@ -1228,6 +1229,14 @@ public actor LibraryRepository { WorkTypeWriter.apply(draft.typeAssignment, to: work) work.genreTags = normalizedTags work.genericNotes = draft.genericNotes+ // Req 7.1: the three V10 columns land on every row of the group+ // beside the fields above. Q19: what the picker showed is what+ // is written, so an unknown stored spelling is replaced rather+ // than preserved — the draft has no representation for a value+ // the picker cannot name.+ work.workStatus = draft.workStatus+ work.readingStatus = draft.readingStatus+ work.verdict = normalizedVerdict work.modifiedAt = timestamp } do { try saveStrategy.save(context) }@@ -1516,7 +1525,7 @@ public actor LibraryRepository { // The four `map*Record` mappers stood here — `Entry`/`Work`/`Site`/ // `TitlePattern` to the V2 `*Record` structs, for the snapshot // `validateStore` validated. They went with it; the live export path has its- // own `map*Record` family in `BackupArchiveProjection`, over `BackupV8Entry`+ // own `map*Record` family in `BackupArchiveProjection`, over `BackupV9Entry` // and friends, and never used these. internal func withLockedContext<Value: Sendable>(@@ -1759,7 +1768,12 @@ public actor LibraryRepository { titleProvenance: work.titleProvenance, createdAt: work.createdAt, modifiedAt: work.modifiedAt,- entries: entries+ entries: entries,+ // The tolerant accessors, like `titleProvenance` above (Q2): an+ // unknown raw value is a library mid-rollout, not corruption.+ workStatus: work.workStatus,+ readingStatus: work.readingStatus,+ verdict: work.verdict ) } @@ -1768,6 +1782,13 @@ public actor LibraryRepository { return left.id.uuidString.lowercased() < right.id.uuidString.lowercased() } + /// Req 2.4 and Q33: the verdict is stored trimmed, with no length limit, and+ /// the trim happens here — beside `normalizeTags` and nowhere else. An empty+ /// verdict is valid under either done-reading status.+ private static func normalizeVerdict(_ verdict: String) -> String {+ verdict.trimmingCharacters(in: .whitespacesAndNewlines)+ }+ private static func normalizeTags(_ tags: [String]) -> [String] { var seen: Set<String> = [] var result: [String] = []
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swiftindex e6b0997..32fcf55 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift@@ -221,12 +221,26 @@ public struct WorkEditBasis: Sendable, Equatable { /// `.manual`, so the title counts as reader-authored only when it is *also* /// something other than what parsing last produced. public let titleProvenance: TitleProvenance-+ /// The three V10 columns the edit started from (Req 7.4). A status or+ /// verdict changed elsewhere between the read and the write is an edit+ /// conflict exactly as a changed title is — `matches` compares all three.+ public let workStatus: WorkStatus+ public let readingStatus: ReadingStatus+ public let verdict: String++ /// The three status parameters are defaulted here and required on+ /// `WorkMetadataDraft` (Q40) because the two fail in opposite directions: a+ /// draft that omits them *writes* the defaults over a reader's values, while+ /// a basis that omits them can only fail to match a non-default survivor —+ /// a refusal the reader sees, never a silent overwrite. public init( displayTitle: String, typeAssignment: WorkTypeAssignment, genreTags: [String], genericNotes: String, memberships: [WorkMembershipBasis], lastParsedTitle: String?,- titleProvenance: TitleProvenance+ titleProvenance: TitleProvenance,+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) { self.displayTitle = displayTitle self.typeAssignment = typeAssignment@@ -235,6 +249,9 @@ public struct WorkEditBasis: Sendable, Equatable { self.memberships = memberships self.lastParsedTitle = lastParsedTitle self.titleProvenance = titleProvenance+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict } public init(work: WorkSnapshot) {@@ -246,7 +263,10 @@ public struct WorkEditBasis: Sendable, Equatable { WorkMembershipBasis(hostname: $0.hostname, urlIdentity: $0.urlIdentity) }, lastParsedTitle: work.lastParsedTitle,- titleProvenance: work.titleProvenance)+ titleProvenance: work.titleProvenance,+ workStatus: work.workStatus,+ readingStatus: work.readingStatus,+ verdict: work.verdict) } /// The basis's first membership hostname, or the empty string — the same
diff --git a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swiftindex 7f0cde1..2e23aa3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift@@ -344,7 +344,7 @@ enum MembershipReconciler { /// alone lands one ULP away from the same instant built through /// `MillisecondInstant`, and the archive's date encoding quantizes on the way /// out — so an unquantized `createdAt` decodes back as a *different* `Date`- /// and `BackupV8Exporter`'s decode-validation refuses the file it just wrote.+ /// and `BackupV9Exporter`'s decode-validation refuses the file it just wrote. /// A library holding a minted membership could not be backed up at all /// ([4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)). /// The value stays a pure function of stored content, so two devices healing
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex 6b4550c..050b392 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -1,42 +1,43 @@ import Foundation import SwiftData -// The live model classes are V9's, nested inside `AsterismSchemaV9` (Decision 6,-// Q20). Top-level typealiases keep every call site (`Entry`, `Site`, …) unchanged.+// The live model classes are V10's, nested inside `AsterismSchemaV10`+// (Decision 6, Q20). Top-level typealiases keep every call site (`Entry`,+// `Site`, …) unchanged. //-// The nesting is what makes the frozen `AsterismSchemaV8` snapshot possible: it+// The nesting is what makes the frozen `AsterismSchemaV9` snapshot possible: it // carries nested classes with the same SwiftData entity names, which is legal // only while there is exactly one *top-level* `@Model` per entity name — and // there are none, because every top-level name here is a typealias. Two // top-level `@Model`s sharing an entity name crash `ModelContext` // (`docs/agent-notes/schema-migration.md`).-public typealias Entry = AsterismSchemaV9.Entry-public typealias Work = AsterismSchemaV9.Work-public typealias Site = AsterismSchemaV9.Site-public typealias TitlePattern = AsterismSchemaV9.TitlePattern-public typealias URLRulePattern = AsterismSchemaV9.URLRulePattern-public typealias WorkTypeEntity = AsterismSchemaV9.WorkTypeEntity+public typealias Entry = AsterismSchemaV10.Entry+public typealias Work = AsterismSchemaV10.Work+public typealias Site = AsterismSchemaV10.Site+public typealias TitlePattern = AsterismSchemaV10.TitlePattern+public typealias URLRulePattern = AsterismSchemaV10.URLRulePattern+public typealias WorkTypeEntity = AsterismSchemaV10.WorkTypeEntity /// V8: a Work's presence on one site (Q3). One row per hostname, carrying that /// site's URL identity, the rule that derived it and the confirmed Work URL — /// and since V9 the only home any of them has.-public typealias WorkSiteMembership = AsterismSchemaV9.WorkSiteMembership+public typealias WorkSiteMembership = AsterismSchemaV10.WorkSiteMembership /// V8: a reader's "not the same work" over an unordered pair of Works (Q20).-public typealias WorkDistinctPair = AsterismSchemaV9.WorkDistinctPair+public typealias WorkDistinctPair = AsterismSchemaV10.WorkDistinctPair // `Character` is deliberately **not** aliased at the top level: the stdlib owns // that name, and shadowing it module-wide would silently retype every // `[Character]` in `MarkdownExport` and `HTMLEntityDecoder` — and every one a // future file writes. The SwiftData entity is still named "Character" (that is // the nested class's name, and what the CloudKit record type and the archive // key on); only the Swift spelling call sites use is qualified.-public typealias CharacterRecord = AsterismSchemaV9.Character-public typealias CharacterSuppression = AsterismSchemaV9.CharacterSuppression+public typealias CharacterRecord = AsterismSchemaV10.Character+public typealias CharacterSuppression = AsterismSchemaV10.CharacterSuppression /// The presentation-enum tolerance policy for the **model accessors**, in one /// place (Q2). /// /// Not the only coercion in the codebase, and not meant to be: the archive /// paths spell their own `?? .default` — `WorkTypeDirectory`,-/// `ArchiveRecordBuilders`, `BackupV8Types`, `BackupArchiveProjection` — each+/// `ArchiveRecordBuilders`, `BackupV9Types`, `BackupArchiveProjection` — each /// answering what *that* wire may say rather than what a stored column may /// hold. Those are deliberately separate policies, not omissions from this one. ///@@ -92,7 +93,7 @@ enum JSONBlob { } } -extension AsterismSchemaV9 {+extension AsterismSchemaV10 { @Model public final class Entry {@@ -316,6 +317,29 @@ public final class Work { public var workTypeID: UUID? public var genreTags: [String] = [] public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+ /// V10: the **author's** side — whether the work is still being written+ /// (Req 1.1). Reader-entered; nothing infers it and a capture never changes+ /// it (Q8).+ ///+ /// Defaulted, non-optional and non-unique, the CloudKit-mirrored shape every+ /// other column here already has — and the initialiser is what SwiftData+ /// turns into the Core Data attribute default, which is what fills the+ /// column on every existing row during the V9 → V10 lightweight stage+ /// (Req 9.1). That is also why `Work.init` and both `create` doors are+ /// untouched: a defaulted column is not an init parameter (Req 2.6).+ public var workStatusRaw: String = WorkStatus.ongoing.rawValue+ /// V10: the **reader's** side — where they stand with the work (Req 2.1).+ /// Same shape and same reasoning as `workStatusRaw`.+ public var readingStatusRaw: String = ReadingStatus.reading.rawValue+ /// V10: the reader's closing thought once they are done with the work+ /// (Req 2.4) — why they stopped under `abandoned`, how they found it under+ /// `finished`. One field serves both (Q6).+ ///+ /// Stored trimmed, with no length limit; the trim happens in `updateWork`,+ /// beside `normalizeTags` and nowhere else (Q33). It survives a reading+ /// status returning to `reading` and is simply not shown (Q7, Req 2.5), so+ /// nothing here clears it.+ public var verdict: String = "" public var createdAt: Date = Date(timeIntervalSince1970: 0) public var modifiedAt: Date = Date(timeIntervalSince1970: 0) /// V7: the fingerprint of the generic-notes text a character-extraction pass@@ -365,6 +389,22 @@ public final class Work { set { titleProvenanceRaw = newValue.rawValue } } + /// Req 1.3: a spelling this build has no case for — an empty string+ /// included — reads as `ongoing` everywhere the status is shown or filtered.+ /// Writing is unaffected (Q19): committing edit mode writes what the picker+ /// showed, and the export still refuses an unrepresentable raw by name+ /// (Req 8.3).+ public var workStatus: WorkStatus {+ get { ToleratedEnum.read(workStatusRaw, default: .ongoing) }+ set { workStatusRaw = newValue.rawValue }+ }++ /// Req 2.7, the same policy on the reader's side, defaulting to `reading`.+ public var readingStatus: ReadingStatus {+ get { ToleratedEnum.read(readingStatusRaw, default: .reading) }+ set { readingStatusRaw = newValue.rawValue }+ }+ public var entryValues: [Entry] { entries ?? [] } public var characterValues: [Character] { characters ?? [] } public var characterSuppressionValues: [CharacterSuppression] { characterSuppressions ?? [] }@@ -1150,7 +1190,7 @@ public final class WorkDistinctPair { } } -} // extension AsterismSchemaV9+} // extension AsterismSchemaV10 /// A rule-derived URL identity a creation site already holds, passed through /// `Work.create` so the minted membership is born carrying it (Req 1.3, 3.1).
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex 36c2750..7c15cfe 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -678,6 +678,33 @@ public enum WorkMergeField: String, Equatable, Hashable, Sendable, CaseIterable case sourceNotes case targetGenreTags case sourceGenreTags+ // V10 (Req 7.3): the merged Work keeps the target's three, and the source's+ // are reported.+ case targetWorkStatus+ case targetReadingStatus+ case targetVerdict+ case sourceWorkStatus+ case sourceReadingStatus+ case sourceVerdict++ /// Whether a **discarded** field's value survives in the merged Work's+ /// notes, which is what the preview has to say about it (Req 7.3).+ ///+ /// The preview captioned every discarded field "recorded in merged notes"+ /// when the only discardable fields were the ones that are. A dropped status+ /// is not written down anywhere, and saying otherwise would be a false+ /// promise on the screen a merge is approved from.+ public var recordedInNotes: Bool {+ switch self {+ case .sourceManualTitle, .sourceWorkURL, .sourceNotes, .sourceVerdict: true+ // The tags are unioned rather than recorded, and nothing on the target's+ // side is discarded at all.+ case .sourceGenreTags, .sourceWorkStatus, .sourceReadingStatus,+ .targetDisplayTitle, .targetType, .targetWorkURL, .targetNotes,+ .targetGenreTags, .targetWorkStatus, .targetReadingStatus, .targetVerdict:+ false+ }+ } } public enum WorkMergeIssue: Equatable, Sendable {@@ -739,6 +766,12 @@ public struct WorkMergeOutcome: Equatable, Sendable { public let sites: [WorkMergeSiteOutcome] public let genericNotes: String public let genreTags: [String]+ /// V10: the target's three, kept whole like `typeDisplay` — the fold never+ /// moves them, so the preview names what the merged Work will carry+ /// (Req 7.3, Q13).+ public let workStatus: WorkStatus+ public let readingStatus: ReadingStatus+ public let verdict: String public let auditBlock: String? public let movedEntryIDs: [UUID] public let resultingEntryCount: Int@@ -773,8 +806,18 @@ public struct WorkMergeOutcome: Equatable, Sendable { retainedFields: [WorkMergeField], discardedFields: [WorkMergeField], sourceDeleted: Bool,- movedCharacterCount: Int = 0+ movedCharacterCount: Int = 0,+ // Defaulted for the same reason `movedCharacterCount` is: an outcome+ // built by hand — the app's merge-model tests do — is describing a+ // preview, and a caller that omits them is describing a Work on the+ // defaults rather than moving anybody's status.+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) {+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict self.movedCharacterCount = movedCharacterCount self.sourceID = sourceID self.targetID = targetID
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift b/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swiftindex df0d792..bf845b7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift@@ -50,15 +50,27 @@ public struct WorkMetadataDraft: Equatable, Sendable { public let typeAssignment: WorkTypeAssignment public let genreTags: [String] public let genericNotes: String+ /// V10, and **not** defaulted (Q40): `updateWork` writes all three to every+ /// row of the group, so a draft built without them would silently reset a+ /// reader's statuses with no compiler error. Every caller states what the+ /// editor showed.+ public let workStatus: WorkStatus+ public let readingStatus: ReadingStatus+ /// Trimmed on write, in `updateWork` and nowhere else (Q33).+ public let verdict: String public init( displayTitle: String, typeAssignment: WorkTypeAssignment, genreTags: [String],- genericNotes: String+ genericNotes: String,+ workStatus: WorkStatus, readingStatus: ReadingStatus, verdict: String ) { self.displayTitle = displayTitle self.typeAssignment = typeAssignment self.genreTags = genreTags self.genericNotes = genericNotes+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict } }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swiftindex 93c9b4e..72c8fe4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift@@ -112,11 +112,21 @@ public struct WorkSnapshot: Equatable, Sendable { public let memberships: [WorkSiteMembershipSnapshot] public let genericNotes: String /// The work's type as a reader sees it, resolved through the directory the- /// read fetched (Req 3.4, 8.6). Replaces the raw `WorkType` this carried:+ /// read fetched (Req 3.4, 8.6). Replaces the raw pre-feature work-type+ /// value this once carried — an enum long since retired with the column: /// the stored columns are not a type on their own, and a snapshot that /// re-derived one would need the directory the read already has. public let typeDisplay: WorkTypeDisplay public let genreTags: [String]+ /// V10: where the *work* stands (Req 2.1), read through the tolerant+ /// accessor, so an unknown stored spelling presents as the default rather+ /// than refusing the read (Req 1.3, 2.7).+ public let workStatus: WorkStatus+ /// V10: where the *reader* stands with it (Req 2.1).+ public let readingStatus: ReadingStatus+ /// V10: the reader's closing thought, stored trimmed (Req 2.4). Kept when a+ /// reading status returns to `reading` and simply not shown (Q7).+ public let verdict: String public let titleProvenance: TitleProvenance public let createdAt: Date public let modifiedAt: Date@@ -142,8 +152,17 @@ public struct WorkSnapshot: Equatable, Sendable { typeDisplay: WorkTypeDisplay = .untyped, genreTags: [String], titleProvenance: TitleProvenance, createdAt: Date, modifiedAt: Date, entries: [EntrySnapshot],- groupState: RecordGroupState<WorkAuthoredContent> = .single+ groupState: RecordGroupState<WorkAuthoredContent> = .single,+ // Defaulted, unlike `WorkMetadataDraft`'s (Q40): a snapshot only ever+ // reads, so a fixture that omits them describes a work on the defaults+ // rather than silently resetting a reader's statuses.+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) {+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict self.groupState = groupState self.id = id self.displayTitle = displayTitle
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swiftindex 5291aeb..8d561be 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift@@ -7,15 +7,27 @@ public enum WorkMergeAuditFormatter { /// site-specific address (Req 3.6), so the site is named on the line — /// otherwise a two-site merge records two `Work URL:` lines the reader /// cannot tell apart.+ /// - Parameter sourceVerdict: the losing side's verdict where it differs+ /// from the kept one (Q41). A structured line beside the URLs rather than+ /// part of the free-form notes below them: it is a field the reader wrote+ /// under a label, and the label is what makes the line readable a year+ /// later. Escaped like the header, and for the same reason: a verdict is+ /// multi-line reader text (Req 2.3), so an unescaped one holding a blank+ /// line would end the structured region early and one holding+ /// `--- Merged from:` would forge a block boundary. public static func block( sourceTitle: String, discardedWorkURLs: [(hostname: String, url: String)] = [],+ sourceVerdict: String = "", sourceNotes: String ) -> String {- var value = "--- Merged from: \(escapedHeader(sourceTitle)) ---"+ var value = "--- Merged from: \(escapedField(sourceTitle)) ---" for discarded in discardedWorkURLs { value += "\nWork URL (\(discarded.hostname)): \(discarded.url)" }+ if !M2Unicode.isBlank(sourceVerdict) {+ value += "\nVerdict: \(escapedField(sourceVerdict))"+ } if !M2Unicode.isBlank(sourceNotes) { value += "\n\n\(sourceNotes)" }@@ -26,7 +38,11 @@ public enum WorkMergeAuditFormatter { M2Unicode.isBlank(targetNotes) ? block : targetNotes + "\n\n" + block } - private static func escapedHeader(_ value: String) -> String {+ /// The one escape for every value that sits in the block's structured+ /// region — the header and the verdict. An unescaped newline there would+ /// split the block, and a `\n\n--- Merged from:` inside a reader's verdict+ /// would forge a boundary the reader never wrote.+ private static func escapedField(_ value: String) -> String { var escaped = "" for scalar in value.unicodeScalars { switch scalar.value {@@ -212,7 +228,12 @@ public enum WorkMergePlanner { retainedFields: retained, discardedFields: discarded, sourceDeleted: true,- movedCharacterCount: basis.movedCharacterCount+ movedCharacterCount: basis.movedCharacterCount,+ // Req 7.3: the target's, as its type and its title are. The commit's+ // target loop writes none of them, so the preview and the row agree.+ workStatus: target.workStatus,+ readingStatus: target.readingStatus,+ verdict: target.verdict ) }
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swiftindex 465f79c..ed19521 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift@@ -26,6 +26,13 @@ public struct WorkVariantSide: Sendable, Equatable { /// A display rather than a bare assignment, because the sheet and the merge /// preview both name the type they are keeping. public let typeDisplay: WorkTypeDisplay+ /// V10: carried like the type — the fold keeps the chosen side's statuses+ /// and reports what the others held (Q13, Req 7.3).+ public let workStatus: WorkStatus+ public let readingStatus: ReadingStatus+ /// Reader text, so unlike the statuses a losing one is *recorded* rather+ /// than merely reported (Q41).+ public let verdict: String public init( displayTitle: String,@@ -33,7 +40,15 @@ public struct WorkVariantSide: Sendable, Equatable { workURLsByHostname: [String: String], genericNotes: String, genreTags: [String],- typeDisplay: WorkTypeDisplay+ typeDisplay: WorkTypeDisplay,+ // **Required**, unlike `WorkSnapshot`'s. Q40's dividing line is about+ // what a *default* costs, and here it costs the verdict: an omitted one+ // is dropped from the audit block, and on the resolution path the row+ // that held it is then deleted. Reader text lost for good, with nothing+ // for the compiler to say. So the caller states all three.+ workStatus: WorkStatus,+ readingStatus: ReadingStatus,+ verdict: String ) { self.displayTitle = displayTitle self.titleProvenance = titleProvenance@@ -41,6 +56,9 @@ public struct WorkVariantSide: Sendable, Equatable { self.genericNotes = genericNotes self.genreTags = genreTags self.typeDisplay = typeDisplay+ self.workStatus = workStatus+ self.readingStatus = readingStatus+ self.verdict = verdict } /// The single-site shape, which is what a duplicate set's variants are: a@@ -53,12 +71,16 @@ public struct WorkVariantSide: Sendable, Equatable { workURLString: String?, genericNotes: String, genreTags: [String],- typeDisplay: WorkTypeDisplay+ typeDisplay: WorkTypeDisplay,+ workStatus: WorkStatus,+ readingStatus: ReadingStatus,+ verdict: String ) { self.init( displayTitle: displayTitle, titleProvenance: titleProvenance, workURLsByHostname: workURLString.map { [hostname: $0] } ?? [:],- genericNotes: genericNotes, genreTags: genreTags, typeDisplay: typeDisplay)+ genericNotes: genericNotes, genreTags: genreTags, typeDisplay: typeDisplay,+ workStatus: workStatus, readingStatus: readingStatus, verdict: verdict) } public init(snapshot: WorkSnapshot) {@@ -70,7 +92,9 @@ public struct WorkVariantSide: Sendable, Equatable { self.init( displayTitle: snapshot.displayTitle, titleProvenance: snapshot.titleProvenance, workURLsByHostname: urls, genericNotes: snapshot.genericNotes,- genreTags: snapshot.genreTags, typeDisplay: snapshot.typeDisplay)+ genreTags: snapshot.genreTags, typeDisplay: snapshot.typeDisplay,+ workStatus: snapshot.workStatus, readingStatus: snapshot.readingStatus,+ verdict: snapshot.verdict) } } @@ -124,8 +148,12 @@ public enum WorkVariantUnion { public static func fold( into chosen: WorkVariantSide, others: [WorkVariantSide] ) -> WorkVariantUnionOutcome {+ // V10: the chosen side's statuses and verdict are seeded unconditionally,+ // exactly as its notes and its type are — the fold never moves them, so+ // the preview names what the merged Work will carry (Req 7.3, Q13). var retained: [WorkMergeField] = [ .targetDisplayTitle, .targetType, .targetNotes, .targetGenreTags,+ .targetWorkStatus, .targetReadingStatus, .targetVerdict, ] var discarded: [WorkMergeField] = [] var workURLs = chosen.workURLsByHostname@@ -169,13 +197,34 @@ public enum WorkVariantUnion { } let urlDiscarded = !discardedURLs.isEmpty + // V10 (Req 7.3, Q38): a status is listed only where the side moved+ // it off its default *and* disagrees with the chosen side — a+ // default contributes nothing (Q14), and agreement is not a+ // discard. Neither is recorded anywhere: the preview says so, and+ // that is the whole of what happens to it.+ let workStatusDiscarded = other.workStatus != .ongoing+ && other.workStatus != chosen.workStatus+ let readingStatusDiscarded = other.readingStatus != .reading+ && other.readingStatus != chosen.readingStatus+ // The verdict is reader text, so a differing one is *recorded* in+ // the audit block as the side's notes are (Q41). A verdict equal to+ // the chosen side's would be noise in the notes (Q38).+ let verdictRecorded = !M2Unicode.isBlank(other.verdict)+ && ExactScalarString(other.verdict) != ExactScalarString(chosen.verdict)+ if titleDiscarded { discarded.append(.sourceManualTitle) } if notesRetained { discarded.append(.sourceNotes) }+ if workStatusDiscarded { discarded.append(.sourceWorkStatus) }+ if readingStatusDiscarded { discarded.append(.sourceReadingStatus) }+ if verdictRecorded { discarded.append(.sourceVerdict) } - if titleDiscarded || urlDiscarded || notesRetained {+ // The verdict joins the gate, so a side differing by verdict alone+ // still produces the block that carries it.+ if titleDiscarded || urlDiscarded || notesRetained || verdictRecorded { let block = WorkMergeAuditFormatter.block( sourceTitle: other.displayTitle, discardedWorkURLs: discardedURLs,+ sourceVerdict: verdictRecorded ? other.verdict : "", sourceNotes: other.genericNotes) blocks.append(block) notes = WorkMergeAuditFormatter.append(block: block, to: notes)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex 32663f3..dc4a1ba 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -34,7 +34,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(Set(payload.sites.map(\.hostname)) == ["present.example", "orphan.example"]) let synthesised = try #require(payload.sites.first { $0.hostname == "orphan.example" })@@ -75,11 +75,11 @@ struct BackupExportDegradedRefusalTests { #expect(await repository.diagnostics.quarantineMap()["quarantined.example"] != nil) let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) - let decoded = try BackupV8Codec.decode(try Data(contentsOf: result.fileURL))+ let decoded = try BackupV9Codec.decode(try Data(contentsOf: result.fileURL)) #expect(decoded.payload.entries.count == 1) #expect(decoded.payload.titlePatterns.count == 2) // The union demoted one of the two, which is what makes the archive legal@@ -102,7 +102,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.sites.count == 1) let site = try #require(payload.sites.first)@@ -140,7 +140,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let pattern = try #require(payload.titlePatterns.first) #expect(pattern.id == patternID)@@ -169,7 +169,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.entries.count == 1) #expect(payload.entries.first?.id == shared)@@ -194,7 +194,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .tornGroups(let payload) = error else { Issue.record("expected .tornGroups, got \(error)")@@ -221,7 +221,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .unrepresentableValue(let record, _, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -235,6 +235,89 @@ struct BackupExportDegradedRefusalTests { #expect(error.description.contains("inferred")) } + /// Req 8.3, the unknown-spelling half. `Work.workStatus` reads through+ /// `ToleratedEnum` so the app can *show* a value a newer build wrote+ /// (Req 1.3) — and the archive may not, because writing `ongoing` back would+ /// record a status the reader never chose. So the export names the work and+ /// the raw text and declines.+ @Test("An unknown work status raw value refuses the export, naming the work and the value")+ func unknownWorkStatusRefusesByName() async throws {+ let fixture = try DegradedExportFixture()+ let workID = UUID()+ try fixture.seed { store in+ store.insertSite(hostname: "present.example")+ let work = store.insertWork(+ id: workID, hostname: "present.example", title: "A Work", offset: 0)+ // The shape a newer app version syncing down produces.+ work.workStatusRaw = "cancelled"+ }+ let repository = try fixture.diagnosedRepository()++ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() }++ guard case .unrepresentableValue(let record, let field, let value) = error else {+ Issue.record("expected .unrepresentableValue, got \(error)")+ return+ }+ #expect(record.contains(workID.uuidString))+ #expect(field == "work status")+ #expect(value == "cancelled")+ #expect(error.description.contains(workID.uuidString))+ #expect(error.description.contains("cancelled"))+ }++ /// The **empty** raw value, which is the same refusal and easy to lose: an+ /// empty string is a spelling no case has, and `ToleratedEnum.read` answers+ /// it with the default exactly as it answers an unknown word (Req 2.7). A+ /// guard written as `raw.isEmpty ? .ongoing : …` would archive it silently.+ @Test("An empty reading status raw value refuses the export by name")+ func emptyReadingStatusRefusesByName() async throws {+ let fixture = try DegradedExportFixture()+ let workID = UUID()+ try fixture.seed { store in+ store.insertSite(hostname: "present.example")+ let work = store.insertWork(+ id: workID, hostname: "present.example", title: "A Work", offset: 0)+ work.readingStatusRaw = ""+ }+ let repository = try fixture.diagnosedRepository()++ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() }++ guard case .unrepresentableValue(let record, let field, let value) = error else {+ Issue.record("expected .unrepresentableValue, got \(error)")+ return+ }+ #expect(record.contains(workID.uuidString))+ #expect(field == "reading status")+ #expect(value.isEmpty)+ }++ /// The positive counterpart: the three fields a reader actually set reach+ /// the wire, so the refusals above are about unrepresentable spellings and+ /// not about the columns being unreadable.+ @Test("A work's statuses and verdict project onto the archive record")+ func statusesProjectOntoTheRecord() async throws {+ let fixture = try DegradedExportFixture()+ let workID = UUID()+ try fixture.seed { store in+ store.insertSite(hostname: "present.example")+ let work = store.insertWork(+ id: workID, hostname: "present.example", title: "A Work", offset: 0)+ work.workStatus = .hiatus+ work.readingStatus = .abandoned+ work.verdict = "Stopped waiting for it."+ }+ let repository = try fixture.diagnosedRepository()++ let payload = try await repository.backupV9Snapshot()++ let record = try #require(payload.works.first { $0.id == workID })+ #expect(record.workStatus == .hiatus)+ #expect(record.readingStatus == .abandoned)+ #expect(record.verdict == "Stopped waiting for it.")+ }+ /// The counter-case, and the reason the test above no longer uses `typeRaw`. /// /// A work's stored type is *not* checked for representability, which is what@@ -255,7 +338,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let record = try #require(payload.works.first { $0.id == workID }) // The 7/8 record has nowhere to put a legacy type at all (Req 10.3, Q16).@@ -277,7 +360,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .unrepresentableValue(_, _, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -317,7 +400,7 @@ struct BackupExportDegradedRefusalTests { memberships: try context.fetch(FetchDescriptor<WorkSiteMembership>())) #expect(omitted == [staleID]) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.urlRules.map(\.id) == [currentID]) #expect(payload.urlRules.first?.siteHostname == payload.sites.first?.hostname)@@ -348,11 +431,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -396,7 +479,7 @@ struct BackupExportDegradedRefusalTests { entries: try context.fetch(FetchDescriptor<Entry>())) #expect(omitted == [retiredID]) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.titlePatterns.map(\.id) == [activeID]) #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname)@@ -424,11 +507,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -460,11 +543,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, let field, _) = error else {@@ -508,7 +591,7 @@ struct BackupExportDegradedRefusalTests { for order in [activeFirst, retiredFirst] { #expect(order.map(\.id) == [sharedID, sharedID]) let error = try #require(- throws: BackupV8ExportError.self,+ throws: BackupV9ExportError.self, "the partition must refuse an all-unreadable group holding the active row" ) { try LibraryRepository.partitionUnreadableTitlePatterns(order, entries: entries)@@ -522,10 +605,10 @@ struct BackupExportDegradedRefusalTests { } let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .unrepresentableValue(let record, _, _) = error else { Issue.record("expected .unrepresentableValue, got \(error)")@@ -554,7 +637,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .referencesStillArriving = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -595,7 +678,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .referencesStillArriving(let detail) = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -623,7 +706,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .referencesStillArriving(let detail) = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -649,11 +732,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .referencesStillArriving = error else {@@ -672,7 +755,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let error = try await expectRefusal { _ = try await repository.backupV8Snapshot() }+ let error = try await expectRefusal { _ = try await repository.backupV9Snapshot() } guard case .referencesStillArriving = error else { Issue.record("expected .referencesStillArriving, got \(error)")@@ -697,11 +780,11 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let error = try await expectRefusal { _ = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) } guard case .tornGroups = error else {@@ -728,7 +811,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let target = try Self.importIntoEmptyStore(payload) // What reconciliation would settle on: one row per hostname holding the@@ -753,7 +836,7 @@ struct BackupExportDegradedRefusalTests { } let repository = try fixture.diagnosedRepository() - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let target = try Self.importIntoEmptyStore(payload) let rows = try target.fetch(FetchDescriptor<Site>())@@ -779,13 +862,13 @@ struct BackupExportDegradedRefusalTests { #expect(await repository.diagnostics.isEmpty) let staging = fixture.directory.appending(path: "staging")- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "1", exportedAt: Date()))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: Date())) - let decoded = try BackupV8Codec.decode(try Data(contentsOf: result.fileURL))- #expect(decoded.backupFormatVersion == 8)- #expect(decoded.databaseSchemaVersion == 9)+ let decoded = try BackupV9Codec.decode(try Data(contentsOf: result.fileURL))+ #expect(decoded.backupFormatVersion == 9)+ #expect(decoded.databaseSchemaVersion == 10) #expect(decoded.payload.entries.count == 1) #expect(decoded.payload.sites.count == 1) exporter.cleanup(result)@@ -795,12 +878,12 @@ struct BackupExportDegradedRefusalTests { private func expectRefusal( _ body: () async throws -> Void- ) async throws -> BackupV8ExportError {+ ) async throws -> BackupV9ExportError { do { try await body() Issue.record("expected a named refusal, but the export proceeded") return .snapshotFailed(reason: "no refusal")- } catch let error as BackupV8ExportError {+ } catch let error as BackupV9ExportError { return error } }@@ -808,13 +891,13 @@ struct BackupExportDegradedRefusalTests { /// The archive's own import path, into a fresh empty store. Both round-trip /// tests go through the strict reference validator on the way in, which is /// what makes "the archive is legal" an assertion rather than a hope.- private static func importIntoEmptyStore(_ payload: BackupV8Payload) throws -> ModelContext {- let encoded = try BackupV8Codec.encode(+ private static func importIntoEmptyStore(_ payload: BackupV9Payload) throws -> ModelContext {+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))- let decoded = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))+ let decoded = try BackupV9Codec.decode(encoded) - let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none) let container = try ModelContainer(for: schema, configurations: [configuration])
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swiftindex 6b1dd6d..dc27e63 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift@@ -4,7 +4,7 @@ import Testing @testable import AsterismCore -/// The byte-for-byte pin on the 8/9 export (T-2281, Req 5.5).+/// The byte-for-byte pin on the 9/10 export (T-2306, Req 8.1). /// /// Nothing in the other suites would notice a change to a key's spelling, a sort /// order, or a number's formatting: they assert on decoded *values*, and a@@ -21,12 +21,13 @@ import Testing /// is a literal, `M5Fixture` runs on a `FixedRepositoryClock`, and the canonical /// encoder sorts keys while the projection sorts every array by identifier. ///-/// **Recorded at 8/9 for T-2281.** An Entry citation is the rule's UUID now, so-/// the citation blobs the archive carries lost their `version` keys, and the+/// **Recorded at 9/10 for T-2306.** A Work carries a work status, a reading+/// status and a verdict now, so every work record gained three keys and the /// envelope moved with them. Re-recording is a deliberate act with a repeatable /// procedure: run this suite's byte test with `ASTERISM_RECORD_GOLDEN=1` set and-/// it writes the fixture and fails, then run it again without the flag (Q22).-@Suite("Backup 8/9 golden export", .serialized)+/// it writes the fixture and fails, then run it again without the flag+/// (`rule-citation-by-uuid` Q22).+@Suite("Backup 9/10 golden export", .serialized) struct BackupGoldenExportTests { /// The recorded archive. Regenerating it is a deliberate act — see the@@ -34,10 +35,10 @@ struct BackupGoldenExportTests { private static var goldenURL: URL { URL(fileURLWithPath: #filePath) .deletingLastPathComponent()- .appending(path: "Fixtures/backup-8-9-golden.json")+ .appending(path: "Fixtures/backup-9-10-golden.json") } - /// Every array the 8/9 payload declares is non-empty, so the golden below is+ /// Every array the 9/10 payload declares is non-empty, so the golden below is /// evidence about the whole projection rather than about the half a smaller /// fixture would reach. @Test("The golden library populates every payload array")@@ -64,6 +65,14 @@ struct BackupGoldenExportTests { #expect(payload.sites.contains { $0.junkSuffixRule != nil }) #expect(payload.sites.contains { $0.mode == .articles }) #expect(payload.titlePatterns.contains { $0.definition.trimSuffix != nil })+ // Req 8.1: a Work carrying all three status fields off their defaults,+ // so the golden bytes pin those spellings and not only `ongoing` /+ // `reading` / the empty verdict.+ #expect(+ payload.works.contains {+ $0.workStatus != .ongoing && $0.readingStatus != .reading+ && !$0.verdict.isEmpty+ }) #expect(payload.entries.contains { $0.canonicalURL != nil }) // Req 9.1: a Work on two sites, which is the shape 6/7 had no record for. #expect(@@ -94,10 +103,10 @@ struct BackupGoldenExportTests { == 1) } - @Test("The 8/9 export of the golden library is byte-identical to the recorded archive")+ @Test("The 9/10 export of the golden library is byte-identical to the recorded archive") func exportIsByteIdenticalToTheRecordedArchive() async throws { let payload = try await Self.exportedPayload()- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload, metadata: BackupGoldenLibrary.metadata) // Q22: every generation bump used to re-record the golden by hand from@@ -119,7 +128,7 @@ struct BackupGoldenExportTests { #expect( encoded == golden, """- the 8/9 export of the golden library no longer produces the recorded \+ the 9/10 export of the golden library no longer produces the recorded \ bytes. An archive's bytes are its identity — the checksum is taken \ over them — so this is a wire-format change unless it is a bug. \ Establish which before re-recording \(Self.goldenURL.lastPathComponent) \@@ -134,8 +143,8 @@ struct BackupGoldenExportTests { let golden = try Data(contentsOf: Self.goldenURL) let plan = try BackupImporter.plan(from: golden) - #expect(plan.metadata.formatVersion == 8)- #expect(plan.metadata.schemaVersion == 9)+ #expect(plan.metadata.formatVersion == 9)+ #expect(plan.metadata.schemaVersion == 10) #expect(plan.counts.entries == plan.metadata.entryCount) #expect(plan.counts.works == plan.metadata.workCount) }@@ -143,9 +152,10 @@ struct BackupGoldenExportTests { // `recordedArchiveRestoresIntoAV9Library` stood here: a 7/8 archive // **exported by a V8 build** restores into a V9 library // (`drop-superseded-columns` Q36). Its evidence was bytes only a V8 build- // could produce, and Req 5.2 makes those unreadable — an 8/9 re-recording- // would prove a different claim, and one `exportImportExportIsByteIdentical`- // below already makes (Q23).+ // could produce, and the one-accepted-pair policy makes those unreadable — a+ // re-recording at the current generation would prove a different claim, and+ // one `exportImportExportIsByteIdentical` below already makes+ // (`rule-citation-by-uuid` Q23). /// Req 9.2, end to end: export, restore into an empty library, export again. /// Byte-identical is the strongest form of "reproduces every record" there@@ -160,13 +170,13 @@ struct BackupGoldenExportTests { /// `recordedArchiveRestoresIntoAV9Library` went away (Q23). @Test("An export imported into an empty library re-exports the same bytes") func exportImportExportIsByteIdentical() async throws {- let first = try BackupV8Codec.encode(+ let first = try BackupV9Codec.encode( payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata) let target = try await M5Fixture() try await target.repository.confirmImport(plan: try BackupImporter.plan(from: first))- let second = try BackupV8Codec.encode(- payload: try await target.repository.backupV8Snapshot(),+ let second = try BackupV9Codec.encode(+ payload: try await target.repository.backupV9Snapshot(), metadata: BackupGoldenLibrary.metadata) #expect(second == first)@@ -178,7 +188,7 @@ struct BackupGoldenExportTests { /// detached, and the dismissed pair imports verbatim. @Test("An orphan membership and a dismissed pair survive the restore") func orphansSurviveTheRestore() async throws {- let archive = try BackupV8Codec.encode(+ let archive = try BackupV9Codec.encode( payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata) let target = try await M5Fixture()@@ -195,17 +205,17 @@ struct BackupGoldenExportTests { /// Imports the golden archive into a fresh library, seeds the duplicate rows /// no write path produces, and exports what results.- private static func exportedPayload() async throws -> BackupV8Payload {+ private static func exportedPayload() async throws -> BackupV9Payload { let fixture = try await M5Fixture() let plan = try BackupImporter.plan(- from: try BackupV8Codec.encode(+ from: try BackupV9Codec.encode( payload: BackupGoldenLibrary.payload, metadata: BackupGoldenLibrary.metadata)) try await fixture.repository.confirmImport(plan: plan) try await fixture.repository.seedM5Rows( sites: BackupGoldenLibrary.duplicateSites, works: BackupGoldenLibrary.duplicateWorks, entries: BackupGoldenLibrary.duplicateEntries)- return try await fixture.repository.backupV8Snapshot()+ return try await fixture.repository.backupV9Snapshot() } } @@ -240,7 +250,7 @@ extension LibraryRepository { } /// The archive the golden library is built from: one record of every kind the-/// 8/9 payload can hold, with literal identifiers and one literal date.+/// 9/10 payload can hold, with literal identifiers and one literal date. enum BackupGoldenLibrary { static let created = Date(timeIntervalSince1970: 1_000_000) @@ -296,14 +306,14 @@ enum BackupGoldenLibrary { static let secondSiteWorkURL = "https://plain.example/works/actual-title" static let articleTitleSuffix = " - Articles Example" - static var metadata: BackupV8Metadata {- BackupV8Metadata(appBuild: "golden", exportedAt: created)+ static var metadata: BackupV9Metadata {+ BackupV9Metadata(appBuild: "golden", exportedAt: created) } // MARK: The archive - static var payload: BackupV8Payload {- BackupV8Payload(+ static var payload: BackupV9Payload {+ BackupV9Payload( entries: [notedEntry, plainEntry, articleEntry], works: [typedWork, foldedWork, legacyWork], sites: [taughtSite, plainSite, articlesSite],@@ -324,8 +334,8 @@ enum BackupGoldenLibrary { } /// The whole-title rule names the Work by trimming the boilerplate prefix.- private static var pattern: BackupV8TitlePattern {- BackupV8TitlePattern(+ private static var pattern: BackupV9TitlePattern {+ BackupV9TitlePattern( id: patternID, siteHostname: taughtHost, version: 1, isActive: true, createdAt: created, definition: StoredPatternDefinition(@@ -333,8 +343,8 @@ enum BackupGoldenLibrary { } /// The articles site's retained history, and the fixture's only `trimSuffix`.- private static var articlePattern: BackupV8TitlePattern {- BackupV8TitlePattern(+ private static var articlePattern: BackupV9TitlePattern {+ BackupV9TitlePattern( id: articlePatternID, siteHostname: articlesHost, version: 1, isActive: false, createdAt: created, definition: StoredPatternDefinition(@@ -342,8 +352,8 @@ enum BackupGoldenLibrary { } /// A sequence-only query rule extracts "94" from the raw URL.- private static var rule: BackupV8URLRule {- BackupV8URLRule(+ private static var rule: BackupV9URLRule {+ BackupV9URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: created, origin: .readerTaught, definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),@@ -352,31 +362,31 @@ enum BackupGoldenLibrary { /// Carries the `junkSuffixRule` column, which no other site in the fixture /// sets and which is therefore absent from the file altogether without it.- private static var taughtSite: BackupV8Site {- BackupV8Site(+ private static var taughtSite: BackupV9Site {+ BackupV9Site( hostname: taughtHost, displayName: "Golden", mode: .taught, junkSuffixRule: try! JunkSuffixRule( version: 1, anchors: [try! SegmentPositionSpec(origin: .end, offset: 0)])) } - private static var plainSite: BackupV8Site {- BackupV8Site(+ private static var plainSite: BackupV9Site {+ BackupV9Site( hostname: plainHost, displayName: "Plain", mode: .untaught, junkSuffixRule: nil) } /// The third site mode. `.articles` may hold neither an active title rule /// nor a current URL rule, so its retained pattern is inactive — which is /// also where the fixture's `trimSuffix` lives.- private static var articlesSite: BackupV8Site {- BackupV8Site(+ private static var articlesSite: BackupV9Site {+ BackupV9Site( hostname: articlesHost, displayName: "Articles", mode: .articles, junkSuffixRule: nil) } private static func workType( id: UUID, name: String, state: WorkTypeState = .active, canonicalID: UUID? = nil- ) -> BackupV8WorkType {- BackupV8WorkType(+ ) -> BackupV9WorkType {+ BackupV9WorkType( id: id, name: name, stateRaw: state.rawValue, canonicalID: canonicalID, createdAt: created, modifiedAt: created) }@@ -385,8 +395,8 @@ enum BackupGoldenLibrary { /// The multi-site Work's first site: a rule-derived identity, its cited rule /// and a confirmed Work URL.- private static var taughtMembership: BackupV8Membership {- BackupV8Membership(+ private static var taughtMembership: BackupV9Membership {+ BackupV9Membership( id: taughtMembershipID, workID: typedWorkID, hostname: taughtHost, createdAt: created, urlIdentity: workIdentity, urlIdentityState: .rule, urlIdentityRuleID: ruleID, workURLString: workURL)@@ -395,22 +405,22 @@ enum BackupGoldenLibrary { /// Its second site (Req 9.1): a different Work URL, no identity, and no /// Entries at all — a membership that outlives its entries (Req 7.1) is the /// ordinary shape after a cross-site merge.- private static var secondSiteMembership: BackupV8Membership {- BackupV8Membership(+ private static var secondSiteMembership: BackupV9Membership {+ BackupV9Membership( id: secondSiteMembershipID, workID: typedWorkID, hostname: plainHost, createdAt: created.addingTimeInterval(1), urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: secondSiteWorkURL) } - private static var plainMembership: BackupV8Membership {- BackupV8Membership(+ private static var plainMembership: BackupV9Membership {+ BackupV9Membership( id: plainMembershipID, workID: foldedWorkID, hostname: plainHost, createdAt: created, urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: nil) } - private static var articleMembership: BackupV8Membership {- BackupV8Membership(+ private static var articleMembership: BackupV9Membership {+ BackupV9Membership( id: articleMembershipID, workID: legacyWorkID, hostname: articlesHost, createdAt: created, urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: nil)@@ -418,17 +428,17 @@ enum BackupGoldenLibrary { /// Req 8.3, Q22: a membership whose Work has not arrived. It imports /// unattached, keeps the Work it names, and is deleted only with that Work.- private static var orphanMembership: BackupV8Membership {- BackupV8Membership(+ private static var orphanMembership: BackupV9Membership {+ BackupV9Membership( id: orphanMembershipID, workID: absentWorkID, hostname: plainHost, createdAt: created, urlIdentity: "plain.example/absent", urlIdentityState: .legacyUnverified, urlIdentityRuleID: nil, workURLString: nil) } /// Req 5.5: the reader said these two are not the same work.- private static var distinctPair: BackupV8DistinctPair {+ private static var distinctPair: BackupV9DistinctPair { let ids = WorkDistinctPair.sortedIDs(typedWorkID, foldedWorkID)- return BackupV8DistinctPair(+ return BackupV9DistinctPair( id: distinctPairID, lowerWorkID: ids.lower, higherWorkID: ids.higher, recordedAt: created) }@@ -437,10 +447,16 @@ enum BackupGoldenLibrary { /// The configured-type work, the one whose generic notes a coverage /// fingerprint describes, and the fixture's two-site Work.- private static var typedWork: BackupV8Work {- BackupV8Work(+ ///+ /// It is also the Work carrying all three V10 status fields **off** their+ /// defaults (Req 8.1), so the golden pins their spellings rather than only+ /// the ones a fresh row would have anyway.+ private static var typedWork: BackupV9Work {+ BackupV9Work( id: typedWorkID, displayTitle: workName, lastParsedTitle: workName, genericNotes: genericNotes, genreTags: ["fantasy"], titleProvenance: .parsed,+ workStatus: .hiatus, readingStatus: .abandoned,+ verdict: "Stalled three years in; I gave up waiting.", workTypeID: novelTypeID, typeName: "novel", createdAt: created, modifiedAt: created, genericNotesExtractionFingerprint: CharacterCoverageFingerprint.of(genericNotes))@@ -448,10 +464,11 @@ enum BackupGoldenLibrary { /// The work citing the **folded** type row, so the import's canonical chase /// and the export's directory both have something to resolve.- private static var foldedWork: BackupV8Work {- BackupV8Work(+ private static var foldedWork: BackupV9Work {+ BackupV9Work( id: foldedWorkID, displayTitle: "Plain Work", lastParsedTitle: nil, genericNotes: "", genreTags: [], titleProvenance: .manual,+ workStatus: .finished, readingStatus: .finished, verdict: "", workTypeID: foldedTypeID, typeName: "novella", createdAt: created, modifiedAt: created) }@@ -459,10 +476,11 @@ enum BackupGoldenLibrary { /// The untyped work. A pre-feature `typeRaw` has not been carried since 7/8 /// (`multi-site-works` Req 10.3, Q16), so this is what such a Work archives /// as.- private static var legacyWork: BackupV8Work {- BackupV8Work(+ private static var legacyWork: BackupV9Work {+ BackupV9Work( id: legacyWorkID, displayTitle: "An Article", lastParsedTitle: nil, genericNotes: "", genreTags: [], titleProvenance: .manual,+ workStatus: .ongoing, readingStatus: .reading, verdict: "", workTypeID: nil, typeName: nil, createdAt: created, modifiedAt: created) }@@ -470,14 +488,14 @@ enum BackupGoldenLibrary { // MARK: The entries /// The v3 key embeds host + resolved Work name + sequence.- private static var notedEntry: BackupV8Entry {+ private static var notedEntry: BackupV9Entry { let rawURL = "https://\(taughtHost)/read?chapter=94&x=1" let key = EntryIdentityKeyV3Codec.encode( try! URLSequenceNameIdentity( hostname: ExactScalarString(taughtHost), workName: ExactScalarString(workName), chapterSequence: ExactScalarString("94")))- return BackupV8Entry(+ return BackupV9Entry( id: notedEntryID, captureTitle: titlePrefix + workName, captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: taughtHost, entryIdentityKey: key, conservativeIdentityKey: rawURL,@@ -496,9 +514,9 @@ enum BackupGoldenLibrary { /// The untaught site's Entry: a conservative key, which is what capture /// writes where no rule has been taught.- private static var plainEntry: BackupV8Entry {+ private static var plainEntry: BackupV9Entry { let rawURL = "https://\(plainHost)/read/7"- return BackupV8Entry(+ return BackupV9Entry( id: plainEntryID, captureTitle: "Plain Work", captureTitleSource: .manual, rawURL: rawURL, canonicalURL: nil, hostname: plainHost, entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,@@ -513,9 +531,9 @@ enum BackupGoldenLibrary { /// The articles site's Entry, and the fixture's only `canonicalURL`: a /// capture whose raw URL carried a tracking parameter the canonical form /// drops.- private static var articleEntry: BackupV8Entry {+ private static var articleEntry: BackupV9Entry { let rawURL = "https://\(articlesHost)/posts/hello?utm_source=share"- return BackupV8Entry(+ return BackupV9Entry( id: articleEntryID, captureTitle: "An Article" + articleTitleSuffix, captureTitleSource: .host, rawURL: rawURL, canonicalURL: "https://\(articlesHost)/posts/hello",@@ -530,8 +548,8 @@ enum BackupGoldenLibrary { // MARK: The characters - private static var guide: BackupV8Character {- BackupV8Character(+ private static var guide: BackupV9Character {+ BackupV9Character( id: guideID, workID: typedWorkID, name: "Grover", nameKey: "grover", aliases: ["Klar"], note: "The guide.", facts: [@@ -544,14 +562,14 @@ enum BackupGoldenLibrary { } /// The sync orphan: a character whose work has not arrived.- private static var orphan: BackupV8Character {- BackupV8Character(+ private static var orphan: BackupV9Character {+ BackupV9Character( id: orphanID, workID: nil, name: "The Stranger", nameKey: "the stranger", aliases: [], note: "", facts: [], createdAt: created, modifiedAt: created) } - private static var candidateSuppression: BackupV8Suppression {- BackupV8Suppression(+ private static var candidateSuppression: BackupV9Suppression {+ BackupV9Suppression( id: candidateSuppressionID, workID: typedWorkID, kindRaw: CharacterSuppressionKind.candidate.rawValue, nameKey: "the crowned one", sourceKindRaw: nil, sourceEntryID: nil, evidence: nil,@@ -559,8 +577,8 @@ enum BackupGoldenLibrary { } /// A fact suppression, which is the shape that carries a source and evidence.- private static var factSuppression: BackupV8Suppression {- BackupV8Suppression(+ private static var factSuppression: BackupV9Suppression {+ BackupV9Suppression( id: factSuppressionID, workID: typedWorkID, kindRaw: CharacterSuppressionKind.fact.rawValue, nameKey: "grover", sourceKindRaw: SourceRef.entry(notedEntryID).kindRaw, sourceEntryID: notedEntryID,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swiftindex 6aef10a..6e89683 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift@@ -36,7 +36,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, sharedAt: 90, title: "Chapter 1") try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.entries.count == 1) let entry = try #require(payload.entries.first)@@ -58,15 +58,15 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }- let encoded = try BackupV8Codec.encode(+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) }+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch)) // The decode gate is the reference validator, which refuses a payload // holding one UUID twice — the shape the projection exists to prevent // reaching it.- let decoded = try BackupV8Codec.decode(encoded)+ let decoded = try BackupV9Codec.decode(encoded) #expect(decoded.payload.entries.count == 1) } @@ -86,7 +86,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-2", capturedAt: 20, work: second, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.works.count == 1) let work = try #require(payload.works.first)@@ -114,7 +114,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: entryID, key: "chapter-1", capturedAt: 30, work: second, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.entries.count == 1) let work = try #require(payload.works.first)@@ -147,7 +147,7 @@ struct BackupGroupProjectionTests { store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, work: work, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } let entry = try #require(payload.entries.first) let archivedWork = try #require(payload.works.first)@@ -157,10 +157,10 @@ struct BackupGroupProjectionTests { // names none (Req 9.3), so an Entry that points nowhere is unattached and // nothing contradicts it. #expect(archivedWork.id == work.id)- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } /// The Definitions' assignment normalisation, in the export (Q106): rows@@ -195,7 +195,7 @@ struct BackupGroupProjectionTests { rowB.editCitations { $0.workAssignment = .manual } try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.entries.count == 1) let entry = try #require(payload.entries.first)@@ -205,10 +205,10 @@ struct BackupGroupProjectionTests { // `entryIDs` lists is gone with the child lists (Req 9.3), and what is // left is the Entry naming one of them. #expect(payload.works.count == 2)- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } // MARK: - Req 8.3: unique-UUID set members never block export@@ -224,7 +224,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 20, note: "from the laptop") try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.entries.count == 2) #expect(Set(payload.entries.map(\.note)) == ["from the phone", "from the laptop"])@@ -242,7 +242,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 1)@@ -268,7 +268,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 2)@@ -288,7 +288,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 1)@@ -321,7 +321,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 1)@@ -357,7 +357,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 2)@@ -393,7 +393,7 @@ struct BackupGroupProjectionTests { try store.commit() let payload = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } #expect(payload.count == 2)@@ -414,7 +414,7 @@ struct BackupGroupProjectionTests { try store.commit() _ = try expectTornRefusal {- _ = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ _ = try store.read { try LibraryRepository.projectV9Payload(context: $0) } } // The resolution outcome: both rows carry the chosen variant (Req@@ -423,7 +423,7 @@ struct BackupGroupProjectionTests { second.note = "from the phone" try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.entries.count == 1) #expect(payload.entries.first?.note == "from the phone") }@@ -441,7 +441,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: first) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.sites.count == 1)@@ -450,10 +450,10 @@ struct BackupGroupProjectionTests { // The archive re-decodes: a payload holding one rule UUID twice is what // the reference validator refuses, and what the store validates it must // be able to export (task 20.4).- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } /// The dedup must not cost a hostname its active title rule.@@ -476,14 +476,14 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true)- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } /// The URL-rule half, which fails *silently* rather than refusing: nothing@@ -502,7 +502,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.isCurrent == true)@@ -536,7 +536,7 @@ struct BackupGroupProjectionTests { #expect(facts.first(where: \.isActive)?.version == 3) #expect(try store.diagnose().quarantineMap().isEmpty) - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true)@@ -558,7 +558,7 @@ struct BackupGroupProjectionTests { store.addEntry(key: "chapter-1", capturedAt: 10, site: site) try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true)@@ -594,7 +594,7 @@ struct BackupGroupProjectionTests { } try store.commit() - let payload = try store.read { try LibraryRepository.projectV8Payload(context: $0) }+ let payload = try store.read { try LibraryRepository.projectV9Payload(context: $0) } let pattern = try #require(payload.titlePatterns.first) #expect(pattern.id == ruleID)@@ -608,7 +608,7 @@ struct BackupGroupProjectionTests { try body() Issue.record("expected a torn-groups refusal, but the export proceeded") return TornGroupsPayload(count: 0, blockingWorkSet: nil)- } catch let error as BackupV8ExportError {+ } catch let error as BackupV9ExportError { guard case .tornGroups(let payload) = error else { Issue.record("expected .tornGroups, got \(error)") return TornGroupsPayload(count: 0, blockingWorkSet: nil)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swiftindex 0a5f4f9..98121ca 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift@@ -38,7 +38,7 @@ struct BackupGroupRoundTripTests { let entryID = UUID() try await repository.seedSplitEntryGroup(id: entryID) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let record = try #require(payload.entries.first) let before = try await repository.entryRows(id: entryID) #expect(before.count == 2)@@ -63,7 +63,7 @@ struct BackupGroupRoundTripTests { let sourceRepository = try await source.open() let entryID = UUID() try await sourceRepository.seedSplitEntryGroup(id: entryID)- let payload = try await sourceRepository.backupV8Snapshot()+ let payload = try await sourceRepository.backupV9Snapshot() let target = try RoundTripEnvironment() let targetRepository = try await target.open()@@ -87,7 +87,7 @@ struct BackupGroupRoundTripTests { let workID = UUID() try await repository.seedSplitWorkGroup(id: workID) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() let record = try #require(payload.works.first) let before = try await repository.workRows(id: workID) #expect(before.count == 2)@@ -117,7 +117,7 @@ struct BackupGroupRoundTripTests { let sourceRepository = try await source.open() let workID = UUID() try await sourceRepository.seedSplitWorkGroup(id: workID)- let payload = try await sourceRepository.backupV8Snapshot()+ let payload = try await sourceRepository.backupV9Snapshot() let target = try RoundTripEnvironment() let targetRepository = try await target.open()@@ -132,8 +132,8 @@ struct BackupGroupRoundTripTests { // MARK: - Citations and rule rows (Req 3.8, 5.1, 5.3) - /// The 8/9 claim, end to end: what an archive says about provenance is what- /// a library restored from it holds.+ /// The 8/9 claim 9/10 inherits, end to end: what an archive says about+ /// provenance is what a library restored from it holds. /// /// The fixture carries the two shapes the version invariant used to refuse — /// a retired title rule at a *higher* version than the marked one, and a@@ -149,7 +149,7 @@ struct BackupGroupRoundTripTests { let sourceRepository = try await source.open() _ = try await sourceRepository.confirmImport(plan: RoundTripEnvironment.plan(payload)) - let exported = try await sourceRepository.backupV8Snapshot()+ let exported = try await sourceRepository.backupV9Snapshot() let target = try RoundTripEnvironment() let targetRepository = try await target.open()@@ -157,7 +157,7 @@ struct BackupGroupRoundTripTests { // Req 5.3's citation half, as decoded values rather than as bytes. let original = try #require(payload.entries.first?.citations)- let citations = try await targetRepository.citations(entryID: BackupV8Fixtures.entryID)+ let citations = try await targetRepository.citations(entryID: BackupV9Fixtures.entryID) #expect(citations == original) // Named, so the comparison above cannot pass on two empty values. #expect(citations?.chapterSequence?.id == Self.citedURLRuleID)@@ -172,7 +172,7 @@ struct BackupGroupRoundTripTests { /// used to refuse are not merely decodable, they **commit**, and the rows /// land holding the versions the archive named. ///- /// `BackupV8ArchiveTests` stops at a decode, which only proves the reference+ /// `BackupV9ArchiveTests` stops at a decode, which only proves the reference /// checks let the file through. Here the same fixture goes through /// `confirmImport` into an empty library: two title patterns share version 1 /// with one of them marked, and the marked URL rule sits *below* a retired@@ -180,7 +180,7 @@ struct BackupGroupRoundTripTests { /// renumbering pass would admit it and move the versions. @Test("An archive with duplicate and non-greatest rule versions imports with its versions") func duplicateAndNonGreatestVersionsImportIntoAnEmptyLibrary() async throws {- let payload = BackupV8Fixtures.duplicateVersionsPayload()+ let payload = BackupV9Fixtures.duplicateVersionsPayload() let host = try #require(payload.sites.first?.hostname) let target = try RoundTripEnvironment()@@ -211,7 +211,7 @@ struct BackupGroupRoundTripTests { private static let citedURLRuleID = UUID( uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")! - private static func expectedPatternRows(_ payload: BackupV8Payload) -> [RuleRowFacts] {+ private static func expectedPatternRows(_ payload: BackupV9Payload) -> [RuleRowFacts] { payload.titlePatterns .map { RuleRowFacts(@@ -221,7 +221,7 @@ struct BackupGroupRoundTripTests { .sorted { $0.id.uuidString < $1.id.uuidString } } - private static func expectedURLRuleRows(_ payload: BackupV8Payload) -> [RuleRowFacts] {+ private static func expectedURLRuleRows(_ payload: BackupV9Payload) -> [RuleRowFacts] { payload.urlRules .map { RuleRowFacts(@@ -234,22 +234,22 @@ struct BackupGroupRoundTripTests { /// `composedPayload` — one taught Site, one cited title rule, one cited URL /// rule, one Entry citing both — plus a retired row of each kind sitting at /// a higher version than the marked one.- private static func versionSpreadPayload() -> BackupV8Payload {- let base = BackupV8Fixtures.composedPayload()+ private static func versionSpreadPayload() -> BackupV9Payload {+ let base = BackupV9Fixtures.composedPayload() let host = "example.com"- let retired = BackupV8Fixtures.created.addingTimeInterval(-60)+ let retired = BackupV9Fixtures.created.addingTimeInterval(-60) - let retiredPattern = BackupV8TitlePattern(+ let retiredPattern = BackupV9TitlePattern( id: UUID(uuidString: "cccccccc-cccc-cccc-cccc-ccccccccccc9")!, siteHostname: host, version: 9, isActive: false, createdAt: retired, definition: StoredPatternDefinition(definition: .wholeTitle))- let retiredRule = BackupV8URLRule(+ let retiredRule = BackupV9URLRule( id: UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd9")!, version: 7, isCurrent: false, createdAt: retired, origin: .readerTaught, definition: .sequence(locator: .query(name: ExactScalarString("part"))), siteHostname: host) - return BackupV8Payload(+ return BackupV9Payload( entries: base.entries, works: base.works, sites: base.sites, titlePatterns: base.titlePatterns + [retiredPattern], urlRules: base.urlRules + [retiredRule],@@ -288,7 +288,7 @@ private struct RoundTripEnvironment { /// The plan `confirmImport` takes, straight off a payload the export just /// produced — which is what a reader restoring their own backup hands it.- static func plan(_ payload: BackupV8Payload) -> BackupImportPlan {+ static func plan(_ payload: BackupV9Payload) -> BackupImportPlan { BackupImportPlan( metadata: BackupImportMetadata( formatVersion: 8, schemaVersion: 9, appBuild: "test-1.0",
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 6c69a4e..98233ea 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -63,6 +63,40 @@ struct BackupImportTransactionTests { #expect(FileManager.default.fileExists(atPath: env.configuration.readinessMarkerURL.path)) } + /// Req 2.6's backup-import arm (Q50). A Work the import materialises reads+ /// its three status fields off the record, exactly as it reads the title+ /// provenance beside them — and a record carrying the defaults lands on the+ /// defaults, which is the only spelling "a record without statuses" still+ /// has: 9/10 makes all three required on the wire (Q34), so the shape that+ /// carried none of them is an 8/9 file this build refuses by version.+ @Test("A materialised work carries the archive record's statuses and verdict")+ func importedWorkCarriesTheStatuses() async throws {+ let env = try TestEnvironment()+ let (_, repository) = try await LibraryRepository.openForApp(env.configuration)++ let markedID = UUID()+ try await repository.confirmImport(+ plan: try makeMinimalImportPlan(+ workID: markedID, workStatus: .hiatus, readingStatus: .abandoned,+ verdict: "Stalled; I stopped waiting."))++ let marked = try #require(try await repository.workStatusFacts(id: markedID))+ #expect(marked.workStatus == .hiatus)+ #expect(marked.readingStatus == .abandoned)+ #expect(marked.verdict == "Stalled; I stopped waiting.")++ // The other arm: a record holding the defaults leaves the row on them,+ // rather than on whatever the previous record happened to write.+ let plainID = UUID()+ try await repository.confirmImport(+ plan: try makeMinimalImportPlan(workID: plainID, entryID: UUID()))++ let plain = try #require(try await repository.workStatusFacts(id: plainID))+ #expect(plain.workStatus == .ongoing)+ #expect(plain.readingStatus == .reading)+ #expect(plain.verdict.isEmpty)+ }+ @Test("Records the archive does not describe survive the import") func importNeverDeletes() async throws { let env = try TestEnvironment()@@ -693,7 +727,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -702,12 +736,12 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV9MigrationPlan.self,+ migrationPlan: AsterismV10MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container) try context.save()- try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("10\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) throws {@@ -716,7 +750,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -725,7 +759,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV9MigrationPlan.self,+ migrationPlan: AsterismV10MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container)@@ -748,7 +782,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration) // seeded already linked. entry.site = site try context.save()- try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("10\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } /// A certified library holding exactly one Site in the given state, for the@@ -769,7 +803,7 @@ private func createReadySiteStore( at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -778,7 +812,7 @@ private func createReadySiteStore( ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV9MigrationPlan.self,+ migrationPlan: AsterismV10MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container)@@ -795,7 +829,7 @@ private func createReadySiteStore( context.insert(pattern) } try context.save()- try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("10\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } /// A certified library holding **two** Site rows for one hostname, each taught@@ -814,7 +848,7 @@ private func createReadyDuplicateSiteStore( at: configuration.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true )- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let storeConfig = ModelConfiguration( "AsterismV3", schema: schema,@@ -823,7 +857,7 @@ private func createReadyDuplicateSiteStore( ) let container = try ModelContainer( for: schema,- migrationPlan: AsterismV9MigrationPlan.self,+ migrationPlan: AsterismV10MigrationPlan.self, configurations: [storeConfig] ) let context = ModelContext(container)@@ -838,7 +872,7 @@ private func createReadyDuplicateSiteStore( context.insert(pattern) } try context.save()- try Data("8\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ try Data("10\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic) } /// A junk-suffix rule with `anchorCount` end-anchored positions — the shape@@ -864,12 +898,12 @@ private func makeSiteDesignationPlan( patternVersion: Int = 1 ) throws -> BackupImportPlan { let epoch = Date(timeIntervalSince1970: 1_800_000_000)- let site = BackupV8Site(+ let site = BackupV9Site( hostname: hostname, displayName: displayName ?? hostname, mode: mode, junkSuffixRule: junkSuffixRule)- let patterns: [BackupV8TitlePattern] = activePattern+ let patterns: [BackupV9TitlePattern] = activePattern ? [- BackupV8TitlePattern(+ BackupV9TitlePattern( // Fixed, not minted: two applications of one archive must match // the same rule row rather than insert a second one. id: patternID,@@ -895,11 +929,11 @@ private func makeSiteDesignationPlan( private func makeBulkImportPlan(entryCount: Int) throws -> BackupImportPlan { let hostname = "bulk.example" let epoch = Date(timeIntervalSince1970: 1_800_000_000)- let site = BackupV8Site(+ let site = BackupV9Site( hostname: hostname, displayName: hostname, mode: .untaught, junkSuffixRule: nil)- let entries = (0..<entryCount).map { index -> BackupV8Entry in+ let entries = (0..<entryCount).map { index -> BackupV9Entry in let rawURL = "https://\(hostname)/read?chapter=\(index)"- return BackupV8Entry(+ return BackupV9Entry( id: UUID(), captureTitle: "Chapter \(index)", captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: hostname, entryIdentityKey: rawURL,@@ -945,7 +979,10 @@ private func makeMinimalImportPlan( workURL: String? = nil, workModifiedAt: Date? = nil, membershipID: UUID? = nil,- membershipCreatedAt: Date? = nil+ membershipCreatedAt: Date? = nil,+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) throws -> BackupImportPlan { let siteHostname = "imported.example.com" let patternID = UUID()@@ -955,7 +992,7 @@ private func makeMinimalImportPlan( let patternProvenance = try FieldProvenance( kind: .pattern, patternID: patternID) - let entry = BackupV8Entry(+ let entry = BackupV9Entry( id: entryID, captureTitle: "Imported Chapter", captureTitleSource: .networkFetch,@@ -981,13 +1018,16 @@ private func makeMinimalImportPlan( workAssignment: .pattern(CitedRule(id: patternID))) ) - let work = BackupV8Work(+ let work = BackupV9Work( id: workID, displayTitle: "Imported Work", lastParsedTitle: "Imported Work", genericNotes: "", genreTags: ["fantasy"], titleProvenance: .parsed,+ workStatus: workStatus,+ readingStatus: readingStatus,+ verdict: verdict, workTypeID: nil, typeName: nil, createdAt: epoch,@@ -996,7 +1036,7 @@ private func makeMinimalImportPlan( // Req 9.1: the Work's site presence is its membership, and Req 9.5 requires // one on the Entry's hostname.- let membership = BackupV8Membership(+ let membership = BackupV9Membership( // Derived from the Work rather than minted: two archives *of one // library* carry the same membership row, which is what makes a // re-import an update rather than a second row on the same hostname.@@ -1010,7 +1050,7 @@ private func makeMinimalImportPlan( workURLString: workURL ) - let pattern = BackupV8TitlePattern(+ let pattern = BackupV9TitlePattern( id: patternID, siteHostname: siteHostname, version: 1,@@ -1022,8 +1062,8 @@ private func makeMinimalImportPlan( ignored: [])) ) - let urlRules: [BackupV8URLRule] = includeURLRule ? [- BackupV8URLRule(+ let urlRules: [BackupV9URLRule] = includeURLRule ? [+ BackupV9URLRule( id: urlRuleID, version: 1, isCurrent: true,@@ -1039,7 +1079,7 @@ private func makeMinimalImportPlan( ) ] : [] - let site = BackupV8Site(+ let site = BackupV9Site( hostname: siteHostname, displayName: siteHostname, mode: .taught,@@ -1047,8 +1087,8 @@ private func makeMinimalImportPlan( ) let metadata = BackupImportMetadata(- formatVersion: 8,- schemaVersion: 9,+ formatVersion: 9,+ schemaVersion: 10, appBuild: "test-1.0", exportedAt: epoch, capabilityGate: "multi-site",@@ -1128,6 +1168,13 @@ private struct SiteFacts: Equatable, Sendable { var activePatterns: Int } +/// The three status fields one Work row holds after an import (Req 2.6).+private struct WorkStatusFacts: Equatable, Sendable {+ var workStatus: WorkStatus+ var readingStatus: ReadingStatus+ var verdict: String+}+ /// One title-rule row after an import: which it is, the version it kept, and /// whether the union left it marked. private struct ImportedPatternFacts: Equatable, Sendable {@@ -1174,6 +1221,19 @@ extension LibraryRepository { } } + /// The three V10 status fields of one Work row, read inside the actor.+ fileprivate func workStatusFacts(id: UUID) async throws -> WorkStatusFacts? {+ try await withLockedContext(mode: .shared, operation: "reading work statuses") { context in+ try context.fetch(FetchDescriptor<Work>())+ .first { $0.id == id }+ .map {+ WorkStatusFacts(+ workStatus: $0.workStatus, readingStatus: $0.readingStatus,+ verdict: $0.verdict)+ }+ }+ }+ fileprivate func membershipFacts() async throws -> [MembershipFacts] { try await withLockedContext(mode: .shared, operation: "reading memberships") { context in try context.fetch(FetchDescriptor<WorkSiteMembership>())
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9ArchiveTests.swiftnew file mode 100644index 0000000..5298478--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9ArchiveTests.swift@@ -0,0 +1,1075 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Archive generation 9/10 (T-2306, Req 8): a Work carries its work status, its+// reading status and the reader's verdict, and the schema number names the store+// the archive was taken from (V10, Q17). The records are otherwise 8/9's — an+// Entry citation is the cited rule's UUID alone, a Work's site presence is a+// membership record, the reader's dismissed pairs travel beside it, no parent+// record names its children, and the coverage table is folded onto the records+// that own it. It **replaces** 8/9 outright (Q34).+//+// Three suites, because the generation has three surfaces and they fail+// differently: the codec answers for the wire shape and its refusals, the+// exporter for what the store projects into it, and the importer for what an+// archive does to a live library.++// MARK: - Codec++@Suite("Backup V9 codec")+struct BackupV9CodecTests {++ @Test("V9 encode/decode round-trips 9/10, the multi-site gate, and the ten arrays")+ func roundTrip() throws {+ let payload = BackupV9Fixtures.payload()++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.backupFormatVersion == 9)+ #expect(decoded.databaseSchemaVersion == 10)+ #expect(decoded.capabilityGate == "multi-site")+ #expect(decoded.payload == payload)+ #expect(decoded.payload.characters == payload.characters)+ #expect(decoded.payload.suppressions == payload.suppressions)+ #expect(decoded.payload.memberships == payload.memberships)+ // Req 9.4: the coverage table is gone and the fingerprints ride on the+ // records whose text they describe.+ #expect(+ decoded.payload.entries.first?.characterExtractionFingerprint+ == BackupV9Fixtures.noteFingerprint)+ #expect(+ decoded.payload.works.first?.genericNotesExtractionFingerprint+ == BackupV9Fixtures.genericNotesFingerprint)+ }++ /// Req 8.1. The two statuses travel as the typed enums, exactly as+ /// `titleProvenance` does, and the verdict as the reader's text — asserted+ /// after a real round-trip over a Work carrying all three off their+ /// defaults, so a dropped field cannot pass as a matching default.+ @Test("A Work's two statuses and verdict survive the round-trip typed")+ func workStatusFieldsRoundTrip() throws {+ let payload = BackupV9Fixtures.composedPayload()++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ let record = try #require(+ decoded.payload.works.first { $0.id == BackupV9Fixtures.composedWorkID })+ #expect(record.workStatus == .finished)+ #expect(record.readingStatus == .abandoned)+ #expect(record.verdict == "Dropped it at the timeskip.")+ #expect(decoded.payload.works == payload.works)+ }++ /// Every field of a character is on the wire, including the fact's citation+ /// and its immutable quote — asserted after a real round-trip rather than+ /// trusted to `Codable`.+ @Test("A character's facts, aliases, note and keys survive the round-trip")+ func characterFieldsRoundTrip() throws {+ let facts = [+ BackupV9Fixtures.fact(),+ BackupV9Fixtures.fact(+ statement: "Knows the way through the pass.",+ quote: "knows the way", source: .genericNotes),+ ]+ let payload = BackupV9Fixtures.payload(+ characters: [+ BackupV9Fixtures.character(aliases: ["Klar", "The Guide"], facts: facts)+ ])++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ let character = try #require(decoded.payload.characters.first)+ #expect(character.name == "Grover")+ #expect(character.nameKey == "grover")+ #expect(character.aliases == ["Klar", "The Guide"])+ #expect(character.note == "The guide.")+ #expect(character.facts.count == 2)+ #expect(character.facts.contains { $0.source == .genericNotes })+ #expect(character.facts.contains { $0.source == .entry(BackupV9Fixtures.entryID) })+ #expect(character.facts.allSatisfy { $0.nameKey == "grover" })+ }++ @Test("Both suppression kinds round-trip with their status and action time")+ func suppressionKindsRoundTrip() throws {+ let rows = [+ BackupV9Fixtures.suppression(),+ BackupV9Fixtures.suppression(+ id: BackupV9Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+ source: .entry(BackupV9Fixtures.entryID), evidence: "promised to guide",+ status: .cleared),+ ]+ let payload = BackupV9Fixtures.payload(suppressions: rows)++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload.suppressions == rows)+ }++ // MARK: The two deliberate exemptions++ /// Q78: the exporter enumerates characters whole, so a character whose work+ /// has not arrived exports with a nil work reference rather than vanishing —+ /// and the validator has to let it through, or the backup refuses over a+ /// tolerated in-flight state (Req 6.7).+ @Test("A character with no work reference validates")+ func orphanCharacterValidates() throws {+ let payload = BackupV9Fixtures.payload(+ characters: [BackupV9Fixtures.character(id: BackupV9Fixtures.orphanID, workID: nil)])++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload.characters.first?.workID == nil)+ }++ /// Decision 2, pinned so it survives refactors: a fact's citation is+ /// tolerated when it dangles. The reader deleted the cited entry, or it has+ /// not synced — neither is corruption, and refusing here would fail the+ /// whole backup over routine curation.+ @Test("A fact citing an entry the archive does not carry validates")+ func danglingFactCitationValidates() throws {+ let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000001")!+ let payload = BackupV9Fixtures.payload(+ characters: [+ BackupV9Fixtures.character(facts: [BackupV9Fixtures.fact(source: .entry(absent))])+ ],+ suppressions: [+ BackupV9Fixtures.suppression(+ id: BackupV9Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+ source: .entry(absent), evidence: "gone")+ ])++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload.characters.first?.facts.first?.source == .entry(absent))+ #expect(decoded.payload.suppressions.first?.sourceEntryID == absent)+ }++ /// The other half of the character rule: optional, but **checked when+ /// present** — the `validateEntry` `workID` pattern.+ @Test("A character naming a work the archive does not carry refuses")+ func characterCitingAnAbsentWorkRefuses() throws {+ let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000002")!+ let payload = BackupV9Fixtures.payload(+ characters: [BackupV9Fixtures.character(workID: absent)])++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ @Test("A suppression naming a work the archive does not carry refuses")+ func suppressionCitingAnAbsentWorkRefuses() throws {+ let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000003")!+ let payload = BackupV9Fixtures.payload(+ suppressions: [BackupV9Fixtures.suppression(workID: absent)])++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ // MARK: Payloads that contradict themselves++ @Test("Two records for one character identity refuse")+ func duplicateCharacterIDRefuses() throws {+ let payload = BackupV9Fixtures.payload(+ characters: [BackupV9Fixtures.character(), BackupV9Fixtures.character()])++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ @Test("Two records for one suppression identity refuse")+ func duplicateSuppressionIDRefuses() throws {+ let payload = BackupV9Fixtures.payload(+ suppressions: [BackupV9Fixtures.suppression(), BackupV9Fixtures.suppression()])++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ // MARK: The two Req 9.5 membership refusals++ /// Req 9.5, first half. An Entry's Work is in the file and holds no+ /// membership on the Entry's hostname: the restored library would start in+ /// exactly the state reconciliation exists to heal, and an archive has to be+ /// wholly legal on arrival (Q50).+ @Test("An Entry whose present Work has no membership on its hostname refuses")+ func entryWithoutAMembershipOnItsHostnameRefuses() throws {+ let base = BackupV9Fixtures.composedPayload()++ // The premise: with the membership present the payload is legal.+ _ = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: base, metadata: BackupV9Fixtures.metadata()))++ let uncovered = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: [])+ let error = #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(+ payload: uncovered, metadata: BackupV9Fixtures.metadata()))+ }+ guard case .unresolvedReference(let type, _, let reference) = error else {+ Issue.record("expected an unresolved reference, got \(String(describing: error))")+ return+ }+ #expect(type == "Entry")+ #expect(reference.contains("membership"))+ }++ /// Req 9.5, second half. Two memberships on one `(workID, hostname)` is a+ /// file that cannot say which row the Work is on — a state sync produces and+ /// the reconciler resolves (Req 2.6, 8.2), and one an archive may not carry.+ @Test("Two memberships for one Work and hostname refuse")+ func duplicateMembershipForOneHostnameRefuses() throws {+ let base = BackupV9Fixtures.composedPayload()+ let twin = BackupV9Fixtures.membership(+ id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee2")!,+ workID: BackupV9Fixtures.composedWorkID, hostname: "example.com")+ let payload = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: base.memberships + [twin])++ let error = #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ guard case .invalidStateTuple(let type, _, let reason) = error else {+ Issue.record("expected an invalid state tuple, got \(String(describing: error))")+ return+ }+ #expect(type == "WorkSiteMembership")+ #expect(reason.contains("example.com"))+ }++ /// Req 9.5's tolerance, and Q22's: a membership or a pair naming a Work the+ /// archive does not carry is an orphan, not a contradiction. It imports+ /// unattached and re-attaches when the Work arrives.+ @Test("A membership and a pair naming an absent Work are accepted")+ func unattachedMembershipAndPairValidate() throws {+ let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000010")!+ let other = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000011")!+ let base = BackupV9Fixtures.composedPayload()+ let orphan = BackupV9Fixtures.membership(+ id: UUID(uuidString: "0adbea00-0000-4000-8000-000000000001")!,+ workID: absent, hostname: "example.com")+ let ids = WorkDistinctPair.sortedIDs(absent, other)+ let payload = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: base.memberships + [orphan],+ distinctPairs: [+ BackupV9DistinctPair(+ id: UUID(uuidString: "0adbea00-0000-4000-8000-000000000002")!,+ lowerWorkID: ids.lower, higherWorkID: ids.higher,+ recordedAt: BackupV9Fixtures.created)+ ])++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload.memberships.contains { $0.workID == absent })+ #expect(decoded.payload.distinctPairs.count == 1)+ }++ /// A membership's own tuple is checked whether or not its Work is here: the+ /// identity arms are the Work arm's, moved to the row that now holds the+ /// value (Req 1.2).+ @Test("A membership whose identity tuple contradicts itself refuses")+ func illegalMembershipTupleRefuses() throws {+ let base = BackupV9Fixtures.composedPayload()+ let illegal = BackupV9Membership(+ id: BackupV9Fixtures.composedMembershipID,+ workID: BackupV9Fixtures.composedWorkID, hostname: "example.com",+ createdAt: BackupV9Fixtures.created, urlIdentity: nil, urlIdentityState: .rule,+ urlIdentityRuleID: nil, workURLString: nil)+ let payload = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: [illegal])++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ /// Q54 retired the membership's `(id, version)` **resolution**, not the+ /// site. A rule the archive carries is one this check can read the hostname+ /// of, and an identity derived on one site by another site's rule is a value+ /// no writer produces — the Entry's identity arm refuses the same shape.+ @Test("A membership citing a rule taught for another site refuses")+ func membershipCitingAnotherSitesRuleRefuses() throws {+ let base = BackupV9Fixtures.composedPayload()+ let otherHost = "other.example"+ let otherRuleID = UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd2")!+ let otherSite = BackupV9Site(+ hostname: otherHost, displayName: "Other", mode: .untaught, junkSuffixRule: nil)+ let otherRule = BackupV9URLRule(+ id: otherRuleID, version: 1, isCurrent: false, createdAt: BackupV9Fixtures.created,+ origin: .importedV2,+ definition: .work(locator: .query(name: ExactScalarString("series"))),+ siteHostname: otherHost)+ // The membership is on example.com and cites other.example's rule.+ let crossSite = BackupV9Fixtures.membership(+ id: BackupV9Fixtures.composedMembershipID,+ workID: BackupV9Fixtures.composedWorkID, hostname: "example.com",+ urlIdentity: "serial-9", urlIdentityState: .rule, urlIdentityRuleID: otherRuleID)+ let payload = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites + [otherSite],+ titlePatterns: base.titlePatterns, urlRules: base.urlRules + [otherRule],+ workTypes: base.workTypes, memberships: [crossSite])++ let error = #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ guard case .invalidStateTuple(let type, _, let reason) = error else {+ Issue.record("expected an invalid state tuple, got \(String(describing: error))")+ return+ }+ #expect(type == "WorkSiteMembership")+ #expect(reason.contains(otherHost))+ }++ /// The other half of Q54, and Q72: a membership whose cited rule the archive+ /// does not carry at all is **accepted**. There is no version to resolve and+ /// no hostname to compare; the row reads as `legacyUnverified` until the rule+ /// arrives, which is a tolerated state rather than a corrupt file.+ @Test("A membership citing a rule the archive does not carry is accepted")+ func membershipCitingAnAbsentRuleValidates() throws {+ let base = BackupV9Fixtures.composedPayload()+ let absentRule = UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd3")!+ let dangling = BackupV9Fixtures.membership(+ id: BackupV9Fixtures.composedMembershipID,+ workID: BackupV9Fixtures.composedWorkID, hostname: "example.com",+ urlIdentity: "serial-9", urlIdentityState: .rule, urlIdentityRuleID: absentRule)+ let payload = BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: [dangling])++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload.memberships.first?.urlIdentityRuleID == absentRule)+ }++ // MARK: The citation arms++ /// Q26: the identity *basis version* is a case now, so the arm the reference+ /// checks open on is the case rather than an integer column. A v3 arm with no+ /// name contributor is the shape the old `identityKeyVersion == 3` branch+ /// refused, and it still refuses.+ @Test("A composed identity with no name contributor refuses")+ func composedIdentityWithoutANameContributorRefuses() throws {+ let payload = BackupV9Fixtures.composedPayload(dropNameContributor: true)++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ /// The other side of the same switch: a `.urlRule` basis whose blob says+ /// `.rawURL` is a record that cannot say which key it holds.+ @Test("A URL-rule basis carrying a raw-URL citation arm refuses")+ func urlRuleBasisWithARawURLArmRefuses() throws {+ let base = BackupV9Fixtures.composedPayload()+ let entry = try #require(base.entries.first)+ let stripped = BackupV9Entry(+ id: entry.id, captureTitle: entry.captureTitle,+ captureTitleSource: entry.captureTitleSource, rawURL: entry.rawURL,+ canonicalURL: entry.canonicalURL, hostname: entry.hostname,+ entryIdentityKey: entry.entryIdentityKey,+ conservativeIdentityKey: entry.conservativeIdentityKey,+ identityBasis: .urlRule, urlWorkIdentity: entry.urlWorkIdentity,+ chapterSequence: entry.chapterSequence, chapterTitle: entry.chapterTitle,+ note: entry.note, rating: entry.rating, firstCapturedAt: entry.firstCapturedAt,+ lastSharedAt: entry.lastSharedAt, modifiedAt: entry.modifiedAt,+ workID: entry.workID, intentionallyUnattached: entry.intentionallyUnattached,+ citations: EntryCitations(identity: .rawURL))+ let payload = BackupV9Payload(+ entries: [stripped], works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: base.memberships)++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))+ }+ }++ @Test("A mismatched version pair around 9/10 is refused by the codec itself")+ func mismatchedPairsRefuse() throws {+ let encoded = try BackupV9Codec.encode(+ payload: BackupV9Fixtures.payload(), metadata: BackupV9Fixtures.metadata())+ var object = try #require(+ try JSONSerialization.jsonObject(with: encoded) as? [String: Any])+ object["databaseSchemaVersion"] = 9++ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(try JSONSerialization.data(withJSONObject: object))+ }+ }++ /// Req 8.2 through the codec: an 8/9 envelope is the pair this generation+ /// replaced, and it is refused at the door rather than half-decoded.+ @Test("An 8/9 envelope is refused by the codec")+ func eightNineEnvelopeRefuses() throws {+ #expect(throws: BackupCodecError.self) {+ try BackupV9Codec.decode(BackupV9Fixtures.retiredGenerationDocument())+ }+ }++ /// A citation is the rule's UUID since 8/9, so `version` is a key the codec+ /// does not write back — and the checksum is taken over the bytes as they+ /// arrived. A 9/10 file carrying one therefore fails the re-encode+ /// comparison, which is the same door every other unrepresentable key meets.+ ///+ /// The paired assertion is what makes this about the key rather than about+ /// the paste: the identical literal with `"version":3` removed decodes.+ @Test("A 9/10 archive whose citation carries a version fails the checksum")+ func citationVersionFailsTheChecksum() throws {+ let document = BackupV9Fixtures.literalDocument(+ payload: BackupV9Fixtures.citationVersionPayloadJSON, entryCount: 1, workCount: 1)++ do {+ _ = try BackupV9Codec.decode(document)+ Issue.record("expected a checksum refusal, but the document decoded")+ } catch let error as BackupCodecError {+ guard case .checksumMismatch = error else {+ Issue.record("expected .checksumMismatch, got \(error)")+ return+ }+ }++ let versionFree = BackupV9Fixtures.literalDocument(+ payload: BackupV9Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)+ let decoded = try BackupV9Codec.decode(versionFree)+ #expect(+ decoded.payload.entries.first?.citations.chapterSequence+ == CitedRule(id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!))+ }++ /// Req 8.1 and Q34 from the wire side: the three status fields are declared+ /// without `decodeIfPresent` and without a default, so a 9/10 document whose+ /// Work record omits them is malformed rather than restorable. A default+ /// would put a reader's abandoned work back as one they are still reading.+ ///+ /// The refusal is a decode failure, not a checksum one — the typed decode+ /// gives up on the missing key before the payload is ever re-encoded — and+ /// the paired assertion pins it to the three keys rather than to the paste:+ /// the identical literal carrying them decodes.+ @Test("A 9/10 Work record omitting the three status fields fails to decode")+ func workOmittingTheStatusFieldsRefuses() throws {+ let document = BackupV9Fixtures.literalDocument(+ payload: BackupV9Fixtures.statusFieldsOmittedPayloadJSON, entryCount: 1, workCount: 1)++ do {+ _ = try BackupV9Codec.decode(document)+ Issue.record("expected a decode refusal, but the document decoded")+ } catch let error as BackupCodecError {+ guard case .decodingFailed = error else {+ Issue.record("expected .decodingFailed, got \(error)")+ return+ }+ }++ let complete = BackupV9Fixtures.literalDocument(+ payload: BackupV9Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)+ let record = try #require(try BackupV9Codec.decode(complete).payload.works.first)+ #expect(record.workStatus == .ongoing)+ #expect(record.readingStatus == .reading)+ #expect(record.verdict.isEmpty)+ }++ /// Req 3.2: the version invariants are retired, so an archive whose Site+ /// holds two title patterns at version 1 and a current URL rule below a+ /// retired one is a file the reference checks accept.+ @Test("An archive with duplicate and non-greatest rule versions decodes")+ func duplicateAndNonGreatestVersionsDecode() throws {+ let payload = BackupV9Fixtures.duplicateVersionsPayload()++ let decoded = try BackupV9Codec.decode(+ try BackupV9Codec.encode(payload: payload, metadata: BackupV9Fixtures.metadata()))++ #expect(decoded.payload == payload)+ #expect(decoded.payload.titlePatterns.map(\.version) == [1, 1])+ #expect(decoded.payload.urlRules.first(where: \.isCurrent)?.version == 2)+ }+}++// MARK: - Export++@Suite("Backup V9 export", .serialized)+struct BackupV9ExportTests {+ private static let host = "characters.example"+ private static let workID = UUID(uuidString: "60000000-0000-4000-8000-000000000001")!+ private static let entryID = UUID(uuidString: "60000000-0000-4000-8000-000000000002")!+ private static let characterID = UUID(uuidString: "60000000-0000-4000-8000-000000000003")!+ private static let orphanID = UUID(uuidString: "60000000-0000-4000-8000-000000000004")!+ private static let early = Date(timeIntervalSince1970: 1_000_000)+ private static let note = "Grover promised to guide them home."+ private static let genericNotes = "The guide is not what he seems."++ @Test("Exporter produces a v9 filename and a valid, decodable 9/10 document")+ func exporterProducesValidDocument() async throws {+ let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+ try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+ defer { try? FileManager.default.removeItem(at: tempDir) }++ let payload = BackupV9Fixtures.payload()+ let exporter = BackupV9Exporter(+ repository: MockV9SnapshotProvider(payload: payload), stagingDirectory: tempDir)+ let result = try await exporter.export(metadata: BackupV9Fixtures.metadata())++ #expect(result.fileURL.lastPathComponent.contains("v9"))+ let decoded = try BackupV9Codec.decode(try Data(contentsOf: result.fileURL))+ #expect(decoded.backupFormatVersion == 9)+ #expect(decoded.databaseSchemaVersion == 10)+ #expect(decoded.payload == payload)+ exporter.cleanup(result)+ }++ /// Req 6.1: what the store holds is what the archive carries — the character+ /// with its facts, the suppression row, and both coverage shapes.+ @Test("Characters, suppressions and coverage project out of the store")+ func charactersProject() throws {+ let store = try LibraryStore()+ store.insertCharacter(+ id: Self.characterID, name: "Grover", aliases: ["Klar"], note: "The guide.",+ facts: [+ CharacterFact(+ statement: "Promised to guide them home.",+ quote: "promised to guide them home", nameKey: "grover",+ source: .entry(Self.entryID))+ ])+ store.insertSuppression(nameKey: "the crowned one")+ store.coverEntry()+ store.coverGenericNotes()+ try store.context.save()++ let payload = try LibraryRepository.projectV9Payload(context: store.context)++ let character = try #require(payload.characters.first)+ #expect(character.id == Self.characterID)+ #expect(character.workID == Self.workID)+ #expect(character.nameKey == "grover")+ #expect(character.aliases == ["Klar"])+ #expect(character.facts.map(\.quote) == ["promised to guide them home"])++ let suppression = try #require(payload.suppressions.first)+ #expect(suppression.nameKey == "the crowned one")+ #expect(suppression.workID == Self.workID)+ #expect(suppression.kindRaw == CharacterSuppressionKind.candidate.rawValue)++ // Req 9.4: no coverage table — the fingerprint is on the record whose+ // text it describes.+ #expect(+ payload.entries.first { $0.id == Self.entryID }?.characterExtractionFingerprint+ == CharacterCoverageFingerprint.of(Self.note))+ #expect(+ payload.works.first { $0.id == Self.workID }?.genericNotesExtractionFingerprint+ == CharacterCoverageFingerprint.of(Self.genericNotes))+ }++ /// `wrong-host-work-url-heal` [1.6](../../../../specs/wrong-host-work-url-heal/requirements.md#16),+ /// Q17: the export folds a duplicated `(Work, hostname)` pair to the row+ /// de-duplication would keep, and the discarded row's Work URL comes with+ /// it. A heal-minted row is in identity state `none`, so a later+ /// rule-identity row for the same hostname sorts ahead of it — and without+ /// the carry the address the heal had just preserved would leave the archive+ /// silently, which is what+ /// [4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)+ /// depends on.+ ///+ /// The fold is a read (Q54): projecting must leave the context clean.+ @Test("The export fold carries a discarded membership's Work URL")+ func exportFoldCarriesTheWorkURL() throws {+ let store = try LibraryStore()+ let work = try #require(try store.context.fetch(FetchDescriptor<Work>()).first)+ let twin = WorkSiteMembership(+ hostname: Self.host, createdAt: Self.early.addingTimeInterval(60),+ urlIdentityState: .none, workURLString: "https://\(Self.host)/serial",+ workID: work.id, work: work)+ store.context.insert(twin)+ try store.context.save()++ let payload = try LibraryRepository.projectV9Payload(context: store.context)++ // One record for the pair, and it is the survivor's — carrying the+ // address the discarded row held.+ #expect(payload.memberships.count == 1)+ #expect(payload.memberships.first?.id != twin.id)+ #expect(payload.memberships.first?.workURLString == "https://\(Self.host)/serial")+ #expect(!store.context.hasChanges, "the projection must not dirty its context")+ }++ /// Q78: enumerated whole, never works→children. A character that synced+ /// ahead of its work is inert in the app, but dropping it from the backup+ /// would be losing reader data to a timing accident.+ @Test("A character whose work has not arrived exports with a nil work reference")+ func orphanCharacterExports() throws {+ let store = try LibraryStore()+ store.insertCharacter(id: Self.orphanID, name: "Stranger", attachToWork: false)+ try store.context.save()++ let payload = try LibraryRepository.projectV9Payload(context: store.context)++ let orphan = try #require(payload.characters.first { $0.id == Self.orphanID })+ #expect(orphan.workID == nil)+ // And the file it produces is legal: the validator's exemption and the+ // exporter's enumeration have to agree, or the export refuses its own bytes.+ let encoded = try BackupV9Codec.encode(+ payload: payload, metadata: BackupV9Fixtures.metadata())+ #expect(try BackupV9Codec.decode(encoded).payload == payload)+ }++ /// Req 6.5. One character UUID over two rows that disagree about something+ /// the reader wrote is one record with two authored values, and an archive+ /// can hold neither of them honestly.+ @Test("A torn character group refuses the export")+ func tornCharacterRefusesExport() throws {+ let store = try LibraryStore()+ store.insertCharacter(id: Self.characterID, name: "Grover", note: "The guide.")+ store.insertCharacter(id: Self.characterID, name: "Grover", note: "A traitor.")+ try store.context.save()++ #expect(throws: BackupV9ExportError.self) {+ try LibraryRepository.projectV9Payload(context: store.context)+ }+ }++ /// Req 6.2 and Decision 2: the export succeeds while a fact's citation+ /// dangles. Deleting a cited entry is curation, not damage.+ @Test("The export succeeds while a fact's citation dangles")+ func danglingCitationExports() throws {+ let absent = UUID(uuidString: "60000000-0000-4000-8000-0000000000ff")!+ let store = try LibraryStore()+ store.insertCharacter(+ id: Self.characterID, name: "Grover",+ facts: [+ CharacterFact(+ statement: "Was there.", quote: "was there", nameKey: "grover",+ source: .entry(absent))+ ])+ try store.context.save()++ let payload = try LibraryRepository.projectV9Payload(context: store.context)++ #expect(payload.characters.first?.facts.first?.source == .entry(absent))+ let encoded = try BackupV9Codec.encode(+ payload: payload, metadata: BackupV9Fixtures.metadata())+ #expect(try BackupV9Codec.decode(encoded).payload == payload)+ }++ // MARK: - Fixture++ /// An in-memory V9 store holding one taught-enough Site, one Work with+ /// generic notes and one noted Entry. The container is retained for the+ /// test's lifetime: a `ModelContext` does not keep its container alive.+ private final class LibraryStore {+ let container: ModelContainer+ let context: ModelContext++ init() throws {+ let schema = Schema(versionedSchema: AsterismSchemaV10.self)+ container = try ModelContainer(+ for: schema,+ configurations: [+ ModelConfiguration(+ schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)+ ])+ context = ModelContext(container)+ let site = Site(hostname: BackupV9ExportTests.host, displayName: "Characters")+ site.mode = .untaught+ context.insert(site)++ let work = Work.create(+ in: context, id: BackupV9ExportTests.workID, title: "A Work",+ hostname: BackupV9ExportTests.host, site: site,+ timestamp: BackupV9ExportTests.early)+ work.genericNotes = BackupV9ExportTests.genericNotes++ let rawURL = "https://\(BackupV9ExportTests.host)/read/1"+ let entry = Entry(+ id: BackupV9ExportTests.entryID, captureTitle: "Chapter 1",+ captureTitleSource: .host, rawURLString: rawURL,+ hostname: BackupV9ExportTests.host, entryIdentityKey: rawURL,+ timestamp: BackupV9ExportTests.early, note: BackupV9ExportTests.note)+ entry.conservativeIdentityKey = rawURL+ entry.editCitations { $0.workAssignment = .manual }+ context.insert(entry)+ entry.site = site+ entry.work = work+ }++ private var work: Work? {+ try? context.fetch(FetchDescriptor<Work>()).first+ }++ func insertCharacter(+ id: UUID, name: String, aliases: [String] = [], note: String = "",+ facts: [CharacterFact] = [], attachToWork: Bool = true+ ) {+ let character = CharacterRecord(+ id: id, name: name, nameKey: CharacterNameKey.normalize(name),+ aliases: aliases, note: note, facts: facts,+ timestamp: BackupV9ExportTests.early)+ context.insert(character)+ if attachToWork { character.work = work }+ }++ func insertSuppression(nameKey: String) {+ let row = CharacterSuppression(+ kind: .candidate, nameKey: nameKey, actionAt: BackupV9ExportTests.early)+ context.insert(row)+ row.work = work+ }++ func coverEntry() {+ try? context.fetch(FetchDescriptor<Entry>()).first?+ .characterExtractionFingerprint = CharacterCoverageFingerprint.of(+ BackupV9ExportTests.note)+ }++ func coverGenericNotes() {+ work?.genericNotesExtractionFingerprint = CharacterCoverageFingerprint.of(+ BackupV9ExportTests.genericNotes)+ }+ }+}++// MARK: - Import++@Suite("Backup 9/10 import", .serialized)+struct BackupV9ImportTests {++ // MARK: One accepted pair (Decision 2)++ @Test("The importer accepts 9/10")+ func acceptedGeneration() throws {+ let data = try BackupV9Codec.encode(+ payload: BackupV9Fixtures.payload(), metadata: BackupV9Fixtures.metadata())++ let plan = try BackupImporter.plan(from: data)+ #expect(plan.metadata.formatVersion == 9)+ #expect(plan.metadata.schemaVersion == 10)+ #expect(plan.payload == BackupImportPayload(BackupV9Fixtures.payload()))+ }++ /// The retired generations refuse **by version**, and the refusal names the+ /// pair the file declares.+ ///+ /// The distinction matters: an 8/9 envelope is well-formed JSON with a+ /// well-formed payload and a valid checksum, so a build that had merely+ /// deleted the 8/9 record types would fail it somewhere inside a decode and+ /// tell the reader their backup is corrupt. It is not corrupt; it is old,+ /// and the message has to say so (Req 8.2).+ ///+ /// (8, 9) leads the list: it is the generation this one replaced, and the+ /// one a reader upgrading across T-2306 is holding.+ @Test(+ "A retired generation refuses by version, naming the pair",+ arguments: [(8, 9), (7, 8), (6, 7), (4, 4), (5, 6), (3, 3)])+ func retiredGenerationsRefuseByVersion(pair: (format: Int, schema: Int)) throws {+ let data = BackupV9Fixtures.retiredGenerationDocument(+ format: pair.format, schema: pair.schema)+ // The envelope is intact — this is a version refusal, not a decode one.+ #expect((try? JSONSerialization.jsonObject(with: data)) != nil)++ let error = #expect(throws: BackupImportError.self) {+ try BackupImporter.plan(from: data)+ }+ guard case .unsupportedFormat(let reason) = error else {+ Issue.record("expected an unsupported-format refusal, got \(String(describing: error))")+ return+ }+ #expect(reason.contains("format \(pair.format)"))+ #expect(reason.contains("schema \(pair.schema)"))+ // Req 8.2 names both pairs: the archive's and the one this build reads.+ #expect(reason.contains("(\(BackupV9Document.formatVersion)/\(BackupV9Document.schemaVersion))"))+ }++ @Test("A mismatched pair around 9/10 is unsupported")+ func mismatchedPairsReject() throws {+ for (format, schema) in [(9, 9), (9, 11), (8, 10), (10, 10)] {+ let data = try JSONSerialization.data(withJSONObject: [+ "backupFormatVersion": format,+ "databaseSchemaVersion": schema,+ ])+ #expect(throws: BackupImportError.self) {+ try BackupImporter.plan(from: data)+ }+ }+ }++ // MARK: What lands (Req 6.1)++ @Test("A 9/10 archive commits its characters, suppressions and coverage")+ func archiveCommits() async throws {+ let fixture = try await M5Fixture()++ let result = try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(BackupV9Fixtures.payload()))+ guard case .committed = result else {+ Issue.record("expected committed, got \(result)")+ return+ }++ let characters = try await fixture.repository.m5AllCharacters()+ let grover = try #require(characters.first { $0.id == BackupV9Fixtures.groverID })+ #expect(grover.name == "Grover")+ #expect(grover.nameKey == "grover")+ #expect(grover.aliases == ["Klar"])+ #expect(grover.note == "The guide.")+ #expect(grover.facts.map(\.quote) == ["promised to guide them home"])+ #expect(grover.facts.first?.source == .entry(BackupV9Fixtures.entryID))+ #expect(grover.workID == BackupV9Fixtures.workID, "the character joins its work")++ let suppressions = try await fixture.repository.m5SuppressionRows()+ let row = try #require(suppressions.first { $0.id == BackupV9Fixtures.suppressionID })+ #expect(row.nameKey == "the crowned one")+ #expect(row.kind == .candidate)+ #expect(row.status == .active)+ #expect(row.workID == BackupV9Fixtures.workID)++ #expect(+ try await fixture.repository.m5EntryCoverage(BackupV9Fixtures.entryID)+ == BackupV9Fixtures.noteFingerprint)+ #expect(+ try await fixture.repository.m5WorkCoverage(BackupV9Fixtures.workID)+ == BackupV9Fixtures.genericNotesFingerprint)+ }++ /// Req 6.7 through the archive: a character with no work is a tolerated+ /// in-flight state on the way out (Q78) and on the way in.+ @Test("An orphan character imports and stays unattached")+ func orphanCharacterImports() async throws {+ let fixture = try await M5Fixture()++ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(+ characters: [+ BackupV9Fixtures.character(id: BackupV9Fixtures.orphanID, workID: nil)+ ])))++ let characters = try await fixture.repository.m5AllCharacters()+ let orphan = try #require(characters.first { $0.id == BackupV9Fixtures.orphanID })+ #expect(orphan.workID == nil)+ }++ /// Q81: coverage carries no timestamp to value-guard with, and needs none —+ /// a pair is kept exactly where the archived fingerprint still describes the+ /// source's current text, and dropped otherwise.+ @Test("Coverage is self-validating: a stale fingerprint is dropped")+ func coverageIsSelfValidating() async throws {+ let fixture = try await M5Fixture()++ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(entryFingerprint: "not-this-note")))++ #expect(try await fixture.repository.m5EntryCoverage(BackupV9Fixtures.entryID) == nil)+ #expect(+ try await fixture.repository.m5WorkCoverage(BackupV9Fixtures.workID)+ == BackupV9Fixtures.genericNotesFingerprint)+ }++ // MARK: Value guards and idempotence (Req 6.1, 7.7's shape)++ /// Over the four tables an archive can move that are not the Work and Entry+ /// rows: the memberships and the pairs are asserted beside the characters and+ /// suppressions, because they are the two the 7/8 format added and the two a+ /// second import could silently rewrite.+ @Test("Importing the same 9/10 archive twice changes nothing the second time")+ func importingTwiceChangesNothing() async throws {+ let fixture = try await M5Fixture()+ let base = BackupV9Fixtures.payload()+ let stranger = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee99")!+ let ids = WorkDistinctPair.sortedIDs(BackupV9Fixtures.workID, stranger)+ let plan = BackupV9Fixtures.plan(+ BackupV9Payload(+ entries: base.entries, works: base.works, sites: base.sites,+ titlePatterns: base.titlePatterns, urlRules: base.urlRules,+ workTypes: base.workTypes, memberships: base.memberships,+ distinctPairs: [+ BackupV9DistinctPair(+ id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee98")!,+ lowerWorkID: ids.lower, higherWorkID: ids.higher,+ recordedAt: BackupV9Fixtures.created)+ ],+ characters: base.characters, suppressions: base.suppressions))++ try await fixture.repository.confirmImport(plan: plan)+ let charactersAfterFirst = try await fixture.repository.m5AllCharacters()+ let suppressionsAfterFirst = try await fixture.repository.m5SuppressionRows()+ let membershipsAfterFirst = try await fixture.repository.m5MembershipRows()+ let pairsAfterFirst = try await fixture.repository.m5DistinctPairRows()++ try await fixture.repository.confirmImport(plan: plan)++ #expect(try await fixture.repository.m5AllCharacters() == charactersAfterFirst)+ #expect(try await fixture.repository.m5SuppressionRows() == suppressionsAfterFirst)+ #expect(try await fixture.repository.m5MembershipRows() == membershipsAfterFirst)+ #expect(try await fixture.repository.m5DistinctPairRows() == pairsAfterFirst)+ #expect(membershipsAfterFirst.count == 1)+ #expect(pairsAfterFirst.count == 1)+ }++ @Test("An archive older than the stored character writes nothing")+ func olderArchiveDoesNotRegressACharacter() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(BackupV9Fixtures.payload()))++ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(+ characters: [+ BackupV9Fixtures.character(+ name: "Renamed by an older device", note: "older",+ modifiedAt: BackupV9Fixtures.created.addingTimeInterval(-1_000))+ ])))++ let grover = try #require(+ try await fixture.repository.m5AllCharacters()+ .first { $0.id == BackupV9Fixtures.groverID })+ #expect(grover.name == "Grover")+ #expect(grover.note == "The guide.")+ }++ @Test("An archive newer than the stored character updates every row of it")+ func newerArchiveUpdatesACharacter() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(BackupV9Fixtures.payload()))++ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(+ characters: [+ BackupV9Fixtures.character(+ name: "Grover Underwood", note: "Still the guide.",+ modifiedAt: BackupV9Fixtures.created.addingTimeInterval(1_000))+ ])))++ let grover = try #require(+ try await fixture.repository.m5AllCharacters()+ .first { $0.id == BackupV9Fixtures.groverID })+ #expect(grover.name == "Grover Underwood")+ #expect(grover.note == "Still the guide.")+ // The retained key never moves with a rename (Q19/Q46) — including a+ // rename that arrives through an archive.+ #expect(grover.nameKey == "grover")+ }++ /// Req 6.6: suppression convergence is the reader's most recent action, and+ /// an archive is not exempt from it.+ @Test("A suppression older than the stored row does not undo a clear")+ func olderSuppressionDoesNotUndoAClear() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(+ suppressions: [+ BackupV9Fixtures.suppression(+ status: .cleared,+ actionAt: BackupV9Fixtures.created.addingTimeInterval(1_000))+ ])))++ try await fixture.repository.confirmImport(+ plan: BackupV9Fixtures.plan(+ BackupV9Fixtures.payload(suppressions: [BackupV9Fixtures.suppression()])))++ let row = try #require(+ try await fixture.repository.m5SuppressionRows()+ .first { $0.id == BackupV9Fixtures.suppressionID })+ #expect(row.status == .cleared)+ }++ // MARK: An archive carrying no characters (Req 6.1)++ /// The other half of Req 6.1: the three character arrays are legitimately+ /// empty, and an archive of a library that has never run an extraction pass+ /// imports with nothing created.+ ///+ /// It was parameterised over the generations that had nowhere to write a+ /// character. Those read paths are gone (Decision 2), so the state is now+ /// reached the only way it still can be — a 9/10 archive whose arrays are+ /// empty.+ @Test("Importing an archive with no characters creates none")+ func archivesWithoutCharactersCreateNone() async throws {+ let fixture = try await M5Fixture()++ let plan = try BackupImporter.plan(+ from: try BackupV9Codec.encode(+ payload: BackupV9Fixtures.composedPayload(),+ metadata: BackupV9Fixtures.metadata()))+ try await fixture.repository.confirmImport(plan: plan)++ #expect(try await fixture.repository.m5AllCharacters().isEmpty)+ #expect(try await fixture.repository.m5SuppressionRows().isEmpty)+ #expect(try await fixture.repository.m5EntryCoverage(BackupV9Fixtures.entryID) == nil)+ }++ // MARK: The round trip (Req 6.1)++ /// The two halves meeting through the real exporter, the real codec and the+ /// real gate: a library holding characters, suppressions and coverage,+ /// exported and restored into a different one.+ @Test("A 9/10 archive exported from one library imports whole into another")+ func exportedArchivesRoundTrip() async throws {+ let source = try await M5Fixture()+ try await source.repository.confirmImport(+ plan: BackupV9Fixtures.plan(BackupV9Fixtures.payload()))++ let payload = try await source.repository.backupV9Snapshot()+ let plan = try BackupImporter.plan(+ from: try BackupV9Codec.encode(+ payload: payload, metadata: BackupV9Fixtures.metadata()))++ let target = try await M5Fixture()+ try await target.repository.confirmImport(plan: plan)++ let characters = try await target.repository.m5AllCharacters()+ let grover = try #require(characters.first { $0.id == BackupV9Fixtures.groverID })+ #expect(grover.name == "Grover")+ #expect(grover.facts.map(\.quote) == ["promised to guide them home"])+ #expect(grover.workID == BackupV9Fixtures.workID)+ #expect(+ try await target.repository.m5SuppressionRows()+ .contains { $0.id == BackupV9Fixtures.suppressionID })+ #expect(+ try await target.repository.m5EntryCoverage(BackupV9Fixtures.entryID)+ == BackupV9Fixtures.noteFingerprint)+ }+}++// MARK: - Test Doubles++private final class MockV9SnapshotProvider: BackupV9SnapshotProviding, @unchecked Sendable {+ let payload: BackupV9Payload+ init(payload: BackupV9Payload) { self.payload = payload }+ func backupV9Snapshot() async throws -> BackupV9Payload { payload }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9Fixtures.swiftnew file mode 100644index 0000000..ddcd409--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV9Fixtures.swift@@ -0,0 +1,640 @@+import CryptoKit+import Foundation++@testable import AsterismCore++/// Shared builders for 9/10 payloads — the only archive shape the app reads or+/// writes.+///+/// It absorbed the 4/4, 5/6, 6/7, 7/8 and 8/9 fixture enums as each generation's+/// read and write paths were deleted. 7/8 changed the records themselves: a Work+/// names no site, a membership record names the Work, the citations travel as+/// one blob, and the coverage table is gone — so a payload here is built+/// site-first, membership-second, and every Entry's Work holds a membership on+/// that Entry's hostname. 8/9 changed what a citation *is* (T-2281): the rule's+/// UUID and nothing else. 9/10 adds a Work's work status, reading status and+/// verdict (T-2306), over a V10 store.+enum BackupV9Fixtures {+ static let created = Date(timeIntervalSince1970: 1_000_000)++ static let novelTypeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000a1")!+ static let webtoonTypeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000a2")!++ /// The Work identity `composedPayload` describes, named once so an import+ /// suite can read the rows it landed on without restating the fixture.+ static let composedWorkID = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")!+ static let workID = composedWorkID+ /// That Work's one membership.+ static let composedMembershipID = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee1")!+ /// The composed fixture's one Entry.+ static let entryID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!++ static let note = "Grover promised to guide them home."+ static let genericNotes = "The guide is not what he seems."+ static let noteFingerprint = CharacterCoverageFingerprint.of(note)+ static let genericNotesFingerprint = CharacterCoverageFingerprint.of(genericNotes)++ static let groverID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000001")!+ static let orphanID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000002")!+ static let strangerID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000003")!+ static let suppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000001")!+ static let factSuppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000002")!++ // MARK: - Work types++ static func workTypeRecord(+ id: UUID,+ name: String,+ state: WorkTypeState = .active,+ canonicalID: UUID? = nil,+ createdAt: Date = created,+ modifiedAt: Date = created+ ) -> BackupV9WorkType {+ BackupV9WorkType(+ id: id, name: name, stateRaw: state.rawValue, canonicalID: canonicalID,+ createdAt: createdAt, modifiedAt: modifiedAt)+ }++ // MARK: - Memberships++ /// A membership in state `none` — what a Work with no rule-derived identity+ /// holds, which is every fixture Work here bar the golden one.+ static func membership(+ id: UUID,+ workID: UUID?,+ hostname: String,+ createdAt: Date = created,+ urlIdentity: String? = nil,+ urlIdentityState: WorkURLIdentityState = .none,+ urlIdentityRuleID: UUID? = nil,+ workURLString: String? = nil+ ) -> BackupV9Membership {+ BackupV9Membership(+ id: id, workID: workID, hostname: hostname, createdAt: createdAt,+ urlIdentity: urlIdentity, urlIdentityState: urlIdentityState,+ urlIdentityRuleID: urlIdentityRuleID, workURLString: workURLString)+ }++ // MARK: - Minimal taught (conservative Entry)++ static let minimalHost = "example.com"+ static let minimalWorkID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")!+ static let minimalMembershipID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1")!+ static let minimalEntryID = UUID(uuidString: "11111111-1111-1111-1111-111111111111")!++ /// A taught Site with one segment title rule and one conservative-basis+ /// Entry. `activePattern: false` breaks the closed tuple; `brokenAlias: true`+ /// breaks the conservative-key alias invariant.+ static func minimalTaughtPayload(+ activePattern: Bool = true,+ brokenAlias: Bool = false,+ workTypeID: UUID? = novelTypeID,+ typeName: String? = "novel",+ workTypes: [BackupV9WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]+ ) -> BackupV9Payload {+ let host = minimalHost+ let patternID = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!+ let rawURL = "https://example.com/read/7"++ let pattern = BackupV9TitlePattern(+ id: patternID, siteHostname: host, version: 1, isActive: activePattern,+ createdAt: created,+ definition: StoredPatternDefinition(+ definition: .segment(+ work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [])))++ let site = BackupV9Site(+ hostname: host, displayName: "Example", mode: .taught, junkSuffixRule: nil)++ let work = BackupV9Work(+ id: minimalWorkID, displayTitle: "Constellation", lastParsedTitle: "Constellation",+ genericNotes: "", genreTags: [], titleProvenance: .parsed,+ workStatus: .ongoing, readingStatus: .reading, verdict: "",+ workTypeID: workTypeID, typeName: typeName,+ createdAt: created, modifiedAt: created)++ let entry = BackupV9Entry(+ id: minimalEntryID, captureTitle: "Chapter 7", captureTitleSource: .host,+ rawURL: rawURL, canonicalURL: nil, hostname: host,+ entryIdentityKey: rawURL,+ conservativeIdentityKey: brokenAlias ? "not-the-url" : rawURL,+ identityBasis: .conservative,+ urlWorkIdentity: nil, chapterSequence: nil, chapterTitle: nil,+ note: "", rating: nil, firstCapturedAt: created, lastSharedAt: created,+ modifiedAt: created, workID: minimalWorkID, intentionallyUnattached: false,+ citations: EntryCitations(workAssignment: .manual))++ return BackupV9Payload(+ entries: [entry], works: [work], sites: [site],+ titlePatterns: [pattern], urlRules: [], workTypes: workTypes,+ memberships: [+ membership(id: minimalMembershipID, workID: minimalWorkID, hostname: host)+ ])+ }++ // MARK: - Composed (whole-title trims + sequence rule + v3 key)++ /// A taught Site whose title rule is a trimmed whole-title rule and whose URL+ /// rule is sequence-only, with one v3-basis (sequence+name) Entry. The URL+ /// extraction, whole-title naming, and v3 key are mutually consistent so the+ /// payload passes the full store-level `LibraryValidator`, not only the+ /// codec-level reference validator. `dropNameContributor: true` removes the+ /// required name contributor.+ static func composedPayload(+ dropNameContributor: Bool = false,+ workTypeID: UUID? = novelTypeID,+ typeName: String? = "novel",+ workTypes: [BackupV9WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]+ ) -> BackupV9Payload {+ let host = "example.com"+ let patternID = UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!+ let ruleID = UUID(uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")!+ let rawURL = "https://example.com/read?chapter=94&x=1"+ let workName = "Actual Title"++ // The whole-title rule names the Work by trimming the boilerplate prefix.+ let pattern = BackupV9TitlePattern(+ id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,+ definition: StoredPatternDefinition(+ definition: .wholeTitle, trimPrefix: "TtH • Story • "))++ // A sequence-only query rule extracts "94" from the raw URL.+ let rule = BackupV9URLRule(+ id: ruleID, version: 1, isCurrent: true, createdAt: created,+ origin: .readerTaught,+ definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),+ siteHostname: host)++ let site = BackupV9Site(+ hostname: host, displayName: "Example", mode: .taught, junkSuffixRule: nil)++ // Req 8.1: the composed fixture's Work carries all three off their+ // defaults, so a round trip that dropped one would show as a difference+ // rather than as a default that happened to match.+ let work = BackupV9Work(+ id: composedWorkID, displayTitle: workName, lastParsedTitle: workName,+ genericNotes: "", genreTags: [], titleProvenance: .parsed,+ workStatus: .finished, readingStatus: .abandoned,+ verdict: "Dropped it at the timeskip.",+ workTypeID: workTypeID, typeName: typeName,+ createdAt: created, modifiedAt: created)++ // The v3 key embeds host + resolved Work name + sequence (Req 4.2).+ let v3Key = EntryIdentityKeyV3Codec.encode(+ try! URLSequenceNameIdentity(+ hostname: ExactScalarString(host), workName: ExactScalarString(workName),+ chapterSequence: ExactScalarString("94")))++ let entry = BackupV9Entry(+ id: entryID, captureTitle: "TtH • Story • Actual Title", captureTitleSource: .host,+ rawURL: rawURL, canonicalURL: nil, hostname: host,+ entryIdentityKey: v3Key, conservativeIdentityKey: rawURL,+ identityBasis: .urlRule,+ urlWorkIdentity: nil, chapterSequence: "94", chapterTitle: nil,+ note: "", rating: nil, firstCapturedAt: created, lastSharedAt: created,+ modifiedAt: created, workID: composedWorkID, intentionallyUnattached: false,+ citations: EntryCitations(+ identity: .composed(+ url: CitedRule(id: ruleID),+ nameTitle: dropNameContributor ? nil : CitedRule(id: patternID)),+ chapterSequence: CitedRule(id: ruleID),+ workAssignment: .pattern(CitedRule(id: patternID))))++ return BackupV9Payload(+ entries: [entry], works: [work], sites: [site],+ titlePatterns: [pattern], urlRules: [rule], workTypes: workTypes,+ memberships: [+ membership(id: composedMembershipID, workID: composedWorkID, hostname: host)+ ])+ }++ // MARK: - Unanchored locators (Req 1.3)++ /// A taught Site whose current rule brackets a path component with the given+ /// anchoring. With `leftAnchored: false` the locator leaves **both** sides+ /// unanchored — which selection would happily resolve on a single-component+ /// path, so the import gate's `validate` call is the only thing standing+ /// between such an archive and the store (Req 1.3, Q5/Q7).+ static func unanchoredRulePayload(leftAnchored: Bool) -> BackupV9Payload {+ let host = "unanchored.example"+ let patternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff1")!+ let ruleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff2")!++ let pattern = BackupV9TitlePattern(+ id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,+ definition: StoredPatternDefinition(+ definition: .segment(+ work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [])))++ let left: PathAnchor = leftAnchored ? .literal(ExactScalarString("series")) : .unanchored+ let rule = BackupV9URLRule(+ id: ruleID, version: 1, isCurrent: true, createdAt: created,+ origin: .readerTaught,+ definition: .work(locator: .pathBracketed(left: left, right: .unanchored)),+ siteHostname: host)++ let site = BackupV9Site(+ hostname: host, displayName: "Unanchored", mode: .taught, junkSuffixRule: nil)++ return BackupV9Payload(+ entries: [], works: [], sites: [site],+ titlePatterns: [pattern], urlRules: [rule], workTypes: [])+ }++ // MARK: - Combined rule, both presence states (Reqs 5.3–5.5)++ static let combinedRuleHost = "combined.example"++ /// A taught Site whose current rule is the tthfanfic-shaped combined rule,+ /// with the chapter sequence declared optional or not.+ ///+ /// Fixed UUIDs and a fixed date, so the encoded bytes are stable and can be+ /// asserted on directly — which a fixture generated by a teaching commit+ /// cannot be (it mints random UUIDs and wall-clock timestamps).+ static func combinedRulePayload(presence: URLSequencePresence) -> BackupV9Payload {+ let patternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff3")!+ let ruleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff4")!++ let pattern = BackupV9TitlePattern(+ id: patternID, siteHostname: combinedRuleHost, version: 1, isActive: true,+ createdAt: created,+ definition: StoredPatternDefinition(definition: .wholeTitle))++ let rule = BackupV9URLRule(+ id: ruleID, version: 1, isCurrent: true, createdAt: created,+ origin: .readerTaught,+ definition: .combined(+ locator: .pathBracketed(left: .start, right: .unanchored),+ template: URLTwoFieldTemplate(+ prefix: ExactScalarString("Story-"),+ separator: ExactScalarString("-"),+ suffix: ExactScalarString(""),+ order: .workThenSequence,+ sequencePresence: presence)),+ siteHostname: combinedRuleHost)++ let site = BackupV9Site(+ hostname: combinedRuleHost, displayName: "Combined", mode: .taught,+ junkSuffixRule: nil)++ return BackupV9Payload(+ entries: [], works: [], sites: [site],+ titlePatterns: [pattern], urlRules: [rule], workTypes: [])+ }++ /// The payload bytes a build **without** the optional-sequence feature+ /// writes for `combinedRulePayload(presence: .required)`: the same records,+ /// hand-written in the codec's canonical `.sortedKeys` layout, and carrying+ /// no `sequencePresence` key anywhere.+ ///+ /// It was recorded at 4/4 when the feature shipped and is restated at each+ /// generation — the envelope and the surrounding arrays move, the rule+ /// definition does not, which is the whole claim the literal exists to pin.+ static let sequencePresenceOmittedPayloadJSON =+ #"{"characters":[],"distinctPairs":[],"entries":[],"memberships":[],"sites":"#+ + #"[{"displayName":"Combined","hostname":"combined.example","mode":"taught"}],"#+ + #""suppressions":[],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","#+ + #""definition":{"definition":{"wholeTitle":{}}},"#+ + #""id":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF3","isActive":true,"#+ + #""siteHostname":"combined.example","version":1}],"urlRules":"#+ + #"[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"combined":"#+ + #"{"locator":{"pathBracketed":{"left":{"start":{}},"right":{"unanchored":{}}}},"#+ + #""template":{"order":"workThenSequence","prefix":"Story-","separator":"-","#+ + #""suffix":""}}},"id":"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFF4","isCurrent":true,"#+ + #""origin":"readerTaught","siteHostname":"combined.example","version":1}],"#+ + #""workTypes":[],"works":[]}"#++ /// A payload literal wrapped in the 9/10 envelope, with the checksum taken+ /// over that literal text rather than over a re-encoding of it.+ ///+ /// The checksum is what makes such a fixture a test rather than a+ /// restatement: `BackupV9Codec.decode` re-encodes the payload it decoded and+ /// compares a SHA-256, so a literal carrying a key the codec does not write+ /// back — an omitted spelling the build no longer produces, or a citation+ /// `version` it now ignores — fails with `checksumMismatch` (Decision 1).+ static func literalDocument(+ payload: String, entryCount: Int = 0, workCount: Int = 0,+ appBuild: String = "literal"+ ) -> Data {+ let checksum = SHA256.hash(data: Data(payload.utf8))+ .map { String(format: "%02x", $0) }.joined()+ return Data(+ (#"{"appBuild":"\#(appBuild)","backupFormatVersion":9,"#+ + #""capabilityGate":"multi-site","checksum":"\#(checksum)","#+ + #""databaseSchemaVersion":10,"entryCount":\#(entryCount),"#+ + #""exportedAt":"1970-01-12T13:46:40.000Z","payload":\#(payload),"#+ + #""workCount":\#(workCount)}"#).utf8)+ }++ static func sequencePresenceOmittedDocument(appBuild: String = "pre-feature") -> Data {+ literalDocument(payload: sequencePresenceOmittedPayloadJSON, appBuild: appBuild)+ }++ // MARK: - A citation carrying a version (Req 5.4)++ /// `composedPayload()` as the current encoder writes it, with `"version":3`+ /// hand-added to the Entry's chapter-sequence citation — the shape a build+ /// before T-2281 wrote, and the one a 9/10 archive may not carry.+ ///+ /// Captured from `BackupCanonicalJSON.encoder()` and pasted, for the reason+ /// Q17 gives: an encoder that no longer writes the key cannot produce+ /// evidence about a file that does.+ static let citationVersionPayloadJSON =+ #"{"characters":[],"distinctPairs":[],"entries":[{"#+ + #""captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","#+ + #""chapterSequence":"94","citations":{"chapterSequence":"#+ + #"{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","version":3},"#+ + #""chapterTitle":{"kind":"none"},"#+ + #""identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"#+ + #""url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":"#+ + #"{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"#+ + #""https://example.com/read?chapter=94&x=1","entryIdentityKey":"#+ + #""v3|h11:example.com|n12:Actual Title|s2:94","#+ + #""firstCapturedAt":"1970-01-12T13:46:40.000Z","#+ + #""hostname":"example.com","id":"22222222-2222-2222-2222-222222222222","#+ + #""identityBasis":"urlRule","intentionallyUnattached":false,"#+ + #""lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","#+ + #""note":"","rawURL":"https://example.com/read?chapter=94&x=1","#+ + #""workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"memberships":"#+ + #"[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"example.com","#+ + #""id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE1","urlIdentityState":"none","#+ + #""workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"sites":"#+ + #"[{"displayName":"Example","hostname":"example.com","mode":"taught"}],"#+ + #""suppressions":[],"#+ + #""titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":"#+ + #"{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"#+ + #""id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"#+ + #""siteHostname":"example.com","version":1}],"urlRules":"#+ + #"[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":"#+ + #"{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","#+ + #""isCurrent":true,"origin":"readerTaught","siteHostname":"example.com","version":1}],"#+ + #""workTypes":[{"createdAt":"1970-01-12T13:46:40.000Z","#+ + #""id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","#+ + #""name":"novel","stateRaw":"active"}],"works":[{"#+ + #""createdAt":"1970-01-12T13:46:40.000Z","#+ + #""displayTitle":"Actual Title","genericNotes":"","genreTags":[],"#+ + #""id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","#+ + #""modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","#+ + #""titleProvenance":"parsed","typeName":"novel","verdict":"","#+ + #""workStatus":"ongoing","#+ + #""workTypeID":"00000000-0000-0000-0000-0000000000A1"}]}"#++ /// The same literal with the stray key removed — the bytes this build writes+ /// for the same records, so the refusal above is pinned to the `version` key+ /// and not to some other drift in the paste.+ static var citationVersionFreePayloadJSON: String {+ citationVersionPayloadJSON.replacingOccurrences(+ of: #"{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","version":3}"#,+ with: #"{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}"#)+ }++ // MARK: - A Work missing the three status fields (Req 8.1)++ /// The bytes this build writes, with `workStatus`, `readingStatus` and+ /// `verdict` struck from the one Work record — the shape a 9/10 file may not+ /// carry, because all three are required and there is no default (Q34).+ ///+ /// It starts from the version-free literal because that is already the+ /// current encoder's output for a payload holding a Work: everything but the+ /// three keys is a file this build accepts, so the refusal is about them.+ static var statusFieldsOmittedPayloadJSON: String {+ citationVersionFreePayloadJSON+ .replacingOccurrences(of: #""readingStatus":"reading","#, with: "")+ .replacingOccurrences(of: #""verdict":"","#, with: "")+ .replacingOccurrences(of: #""workStatus":"ongoing","#, with: "")+ }++ // MARK: - Duplicate and non-greatest rule versions (Req 3.2)++ /// A Site holding two title patterns at version 1 — one retired, one active+ /// — and a current URL rule whose version is *below* the retired rule's.+ ///+ /// Both shapes were refusals until T-2281 retired the invariant: a Site's+ /// rule versions had to be unique and the marked row had to hold the+ /// greatest. The column is advisory now, so this archive imports.+ static func duplicateVersionsPayload() -> BackupV9Payload {+ let host = "versions.example"+ let retiredPatternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff5")!+ let activePatternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff6")!+ let retiredRuleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff7")!+ let currentRuleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff8")!++ func pattern(_ id: UUID, active: Bool, createdAt: Date) -> BackupV9TitlePattern {+ BackupV9TitlePattern(+ id: id, siteHostname: host, version: 1, isActive: active, createdAt: createdAt,+ definition: StoredPatternDefinition(definition: .wholeTitle))+ }++ func rule(_ id: UUID, version: Int, current: Bool, createdAt: Date) -> BackupV9URLRule {+ BackupV9URLRule(+ id: id, version: version, isCurrent: current, createdAt: createdAt,+ origin: .readerTaught,+ definition: .sequence(+ locator: .pathBracketed(left: .literal(ExactScalarString("c")), right: .end)),+ siteHostname: host)+ }++ return BackupV9Payload(+ entries: [], works: [],+ sites: [+ BackupV9Site(+ hostname: host, displayName: "Versions", mode: .taught, junkSuffixRule: nil)+ ],+ titlePatterns: [+ pattern(retiredPatternID, active: false, createdAt: created),+ pattern(activePatternID, active: true, createdAt: created.addingTimeInterval(60)),+ ],+ urlRules: [+ rule(retiredRuleID, version: 9, current: false, createdAt: created),+ rule(+ currentRuleID, version: 2, current: true,+ createdAt: created.addingTimeInterval(60)),+ ],+ workTypes: [])+ }++ // MARK: - Two current URL rules (illegal)++ static func twoCurrentRulePayload() -> BackupV9Payload {+ let host = "dup.example"+ let patternID = UUID()+ let ruleA = UUID()+ let ruleB = UUID()++ let pattern = BackupV9TitlePattern(+ id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,+ definition: StoredPatternDefinition(+ definition: .segment(+ work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),+ ignored: [])))++ func rule(_ id: UUID, _ version: Int) -> BackupV9URLRule {+ BackupV9URLRule(+ id: id, version: version, isCurrent: true, createdAt: created,+ origin: .readerTaught,+ definition: .sequence(+ locator: .pathBracketed(left: .literal(ExactScalarString("c")), right: .end)),+ siteHostname: host)+ }++ let site = BackupV9Site(+ hostname: host, displayName: "Dup", mode: .taught, junkSuffixRule: nil)++ return BackupV9Payload(+ entries: [], works: [], sites: [site],+ titlePatterns: [pattern], urlRules: [rule(ruleA, 1), rule(ruleB, 2)],+ workTypes: [])+ }++ // MARK: - Character records++ static func fact(+ statement: String = "Promised to guide them home.",+ quote: String = "promised to guide them home",+ nameKey: String = "grover",+ source: SourceRef = .entry(entryID)+ ) -> CharacterFact {+ CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+ }++ static func character(+ id: UUID = groverID,+ workID: UUID? = workID,+ name: String = "Grover",+ nameKey: String = "grover",+ aliases: [String] = ["Klar"],+ note: String = "The guide.",+ facts: [CharacterFact] = [fact()],+ createdAt: Date = created,+ modifiedAt: Date = created+ ) -> BackupV9Character {+ BackupV9Character(+ id: id, workID: workID, name: name, nameKey: nameKey, aliases: aliases,+ note: note, facts: facts, createdAt: createdAt, modifiedAt: modifiedAt)+ }++ static func suppression(+ id: UUID = suppressionID,+ workID: UUID? = workID,+ kind: CharacterSuppressionKind = .candidate,+ nameKey: String = "the crowned one",+ source: SourceRef? = nil,+ evidence: String? = nil,+ status: CharacterSuppressionStatus = .active,+ actionAt: Date = created+ ) -> BackupV9Suppression {+ BackupV9Suppression(+ id: id, workID: workID, kindRaw: kind.rawValue, nameKey: nameKey,+ sourceKindRaw: source?.kindRaw, sourceEntryID: source?.entryID,+ evidence: evidence, statusRaw: status.rawValue, actionAt: actionAt)+ }++ // MARK: - Payloads++ /// The composed payload with a noted Entry, generic notes on the Work, and+ /// whatever character records the caller asks for.+ ///+ /// The coverage fingerprints ride on the two records whose text they describe+ /// (Req 9.4) rather than in a table of their own. They are still+ /// self-validating against that *text* (Q81), so the fixture's sources carry+ /// it and the defaults are taken from them — a caller passing something else+ /// is describing a stale pair on purpose.+ static func payload(+ characters: [BackupV9Character] = [character()],+ suppressions: [BackupV9Suppression] = [suppression()],+ entryFingerprint: String? = noteFingerprint,+ workFingerprint: String? = genericNotesFingerprint+ ) -> BackupV9Payload {+ let base = composedPayload()+ return BackupV9Payload(+ entries: base.entries.map { noted($0, fingerprint: entryFingerprint) },+ works: base.works.map { annotated($0, fingerprint: workFingerprint) },+ sites: base.sites,+ titlePatterns: base.titlePatterns,+ urlRules: base.urlRules,+ workTypes: base.workTypes,+ memberships: base.memberships,+ distinctPairs: base.distinctPairs,+ characters: characters,+ suppressions: suppressions)+ }++ static func metadata(appBuild: String = "test-8", exportedAt: Date = created)+ -> BackupV9Metadata+ {+ BackupV9Metadata(appBuild: appBuild, exportedAt: exportedAt)+ }++ static func plan(_ payload: BackupV9Payload) -> BackupImportPlan {+ BackupImportPlan(+ metadata: BackupImportMetadata(+ formatVersion: 8, schemaVersion: 9, appBuild: "test-8", exportedAt: created,+ capabilityGate: "multi-site", entryCount: payload.entries.count,+ workCount: payload.works.count),+ payload: payload,+ counts: LibraryRecordCounts(+ entries: payload.entries.count, works: payload.works.count,+ sites: payload.sites.count, titlePatterns: payload.titlePatterns.count,+ urlRulePatterns: payload.urlRules.count, workTypes: payload.workTypes.count))+ }++ // MARK: - A refused envelope++ /// An 8/9 envelope, hand-written because nothing in the app can mint one any+ /// more. Structurally valid JSON with a well-formed payload: what makes it+ /// unimportable is the version pair, which is exactly the distinction the+ /// refusal has to draw (Req 8.2).+ ///+ /// 8/9 is the generation immediately behind this one, and the one a reader is+ /// most likely to still hold: an archive exported before T-2306 carries no+ /// work status, reading status or verdict, values this build would have to+ /// invent (Q17).+ static func retiredGenerationDocument(format: Int = 8, schema: Int = 9) -> Data {+ let payload = #"{"entries":[],"sites":[],"titlePatterns":[],"urlRules":[],"works":[]}"#+ let checksum = SHA256.hash(data: Data(payload.utf8))+ .map { String(format: "%02x", $0) }.joined()+ return Data(+ (#"{"appBuild":"retired","backupFormatVersion":\#(format),"capabilityGate":"m4","#+ + #""checksum":"\#(checksum)","databaseSchemaVersion":\#(schema),"entryCount":0,"#+ + #""exportedAt":"1970-01-12T13:46:40.000Z","payload":\#(payload),"#+ + #""workCount":0}"#).utf8)+ }++ // MARK: - Copies of the frozen records++ /// The composed Entry with a note and its covered revision. `BackupV9Entry`'s+ /// fields are `let`, so a copy is a full restatement — stated once here+ /// rather than in each suite.+ private static func noted(_ record: BackupV9Entry, fingerprint: String?) -> BackupV9Entry {+ BackupV9Entry(+ id: record.id, captureTitle: record.captureTitle,+ captureTitleSource: record.captureTitleSource, rawURL: record.rawURL,+ canonicalURL: record.canonicalURL, hostname: record.hostname,+ entryIdentityKey: record.entryIdentityKey,+ conservativeIdentityKey: record.conservativeIdentityKey,+ identityBasis: record.identityBasis,+ urlWorkIdentity: record.urlWorkIdentity,+ chapterSequence: record.chapterSequence,+ chapterTitle: record.chapterTitle,+ note: note, rating: record.rating, firstCapturedAt: record.firstCapturedAt,+ lastSharedAt: record.lastSharedAt, modifiedAt: record.modifiedAt,+ workID: record.workID, intentionallyUnattached: record.intentionallyUnattached,+ citations: record.citations,+ characterExtractionFingerprint: fingerprint)+ }++ /// The composed Work with generic notes and its covered revision.+ private static func annotated(_ record: BackupV9Work, fingerprint: String?) -> BackupV9Work {+ BackupV9Work(+ id: record.id, displayTitle: record.displayTitle,+ lastParsedTitle: record.lastParsedTitle, genericNotes: genericNotes,+ genreTags: record.genreTags, titleProvenance: record.titleProvenance,+ workStatus: record.workStatus, readingStatus: record.readingStatus,+ verdict: record.verdict,+ workTypeID: record.workTypeID, typeName: record.typeName,+ createdAt: record.createdAt, modifiedAt: record.modifiedAt,+ genericNotesExtractionFingerprint: fingerprint)+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swiftindex bcaf979..59adf92 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift@@ -119,7 +119,7 @@ struct BootstrapActionTests { // are not what "empty" means here: the guard asks whether anything of the // *reader's* would be certified sight unseen. #expect(result == .ready(.seededEmpty))- #expect(try root.markerText() == "9",+ #expect(try root.markerText() == "10", "a crash between store creation and the marker is repaired, not terminal") withExtendedLifetime(root) {} }@@ -222,7 +222,7 @@ struct BootstrapActionTests { // the owner's library (Decision 5) — and the container construction is // what fails. try Data("this is not a sqlite store".utf8).write(to: root.storeURL, options: .atomic)- try root.writeMarker("9\n")+ try root.writeMarker("10\n") let before = try root.digest() await #expect(throws: (any Error).self) {@@ -260,10 +260,10 @@ private enum RefusedState: String, CaseIterable, Sendable { switch self { case .storeRecordedBelowV5: try root.installStoreRecordedAtFourZeroZero()- try root.writeMarker("9\n")+ try root.writeMarker("10\n") case .readinessMarkerWithoutAStore: try root.createStoreDirectory()- try root.writeMarker("9\n")+ try root.writeMarker("10\n") case .historicalMarkerWithoutAStore: try root.createStoreDirectory() try root.writeHistoricalMarker()@@ -272,7 +272,7 @@ private enum RefusedState: String, CaseIterable, Sendable { try root.writeMigrationArtefact() case .markerRecordingAnUnknownVersion: try await root.seedReadyLibrary(hostname: "unknown.example")- try root.writeMarker("10\n")+ try root.writeMarker("99\n") case .markerThatIsNotText: try await root.seedReadyLibrary(hostname: "bytes.example") try root.writeMarkerBytes(ActionRoot.nonUTF8MarkerBytes)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swiftindex 083d619..003b736 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift@@ -18,14 +18,14 @@ import Testing /// | Axis | Values | /// |---|---| /// | Store family | absent / main file only / companions only / full family |-/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"8"` / `"9"` / unrecognised text / non-UTF-8 bytes |+/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"8"` / `"9"` / `"10"` / unrecognised text / non-UTF-8 bytes | /// | Historical marker | present / absent | /// | Migration artefact | present / absent | /// | Recorded version | at-or-above V5 / below / indeterminate | ///-/// 384 combinations, of which two collapse across the version axis: with no main+/// 432 combinations, of which two collapse across the version axis: with no main /// file there is nothing to read, so the store-absent and companions-only rows-/// have one version value rather than three. That leaves 256 distinct cells,+/// have one version value rather than three. That leaves 288 distinct cells, /// small enough to enumerate rather than sample. /// /// Every cell asserts three things:@@ -81,11 +81,11 @@ struct BootstrapClassifierTests { /// `bothMarkersV4Governs` as a classification: the historical marker is a /// leftover, and the row that matches first wins.- @Test("An \"8\" marker beside a stale historical marker classifies ready")+ @Test("A \"10\" marker beside a stale historical marker classifies ready") func readyMarkerGovernsOverAHistoricalMarker() throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()- try root.writeMarker("9\n")+ try root.writeMarker("10\n") try root.writeHistoricalMarker() #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -95,11 +95,11 @@ struct BootstrapClassifierTests { /// Req 1.2 forbids *resuming from* a migration artefact, not tolerating one. /// A certified library that still carries one is ready, and the artefact is /// cleared after the open rather than being allowed to refuse it.- @Test("An \"8\" marker beside a leftover migration artefact classifies ready")+ @Test("A \"10\" marker beside a leftover migration artefact classifies ready") func readyMarkerGovernsOverALeftoverArtefact() throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()- try root.writeMarker("9\n")+ try root.writeMarker("10\n") try root.writeMigrationArtefact() #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -155,7 +155,7 @@ struct BootstrapClassifierTests { let root = try ClassifierRoot() try root.createStoreDirectory() switch kind {- case .readinessMarker: try root.writeMarker("8\n")+ case .readinessMarker: try root.writeMarker("9\n") case .historicalMarker: try root.writeHistoricalMarker() case .migrationSidecar: try root.writeMigrationArtefact() }@@ -171,7 +171,7 @@ struct BootstrapClassifierTests { func overlappingOrphanedEvidenceNamesTheReadinessMarker() throws { let root = try ClassifierRoot() try root.createStoreDirectory()- try root.writeMarker("8\n")+ try root.writeMarker("9\n") try root.writeHistoricalMarker() try root.writeMigrationArtefact() @@ -180,23 +180,22 @@ struct BootstrapClassifierTests { withExtendedLifetime(root) {} } - /// The four retired generations. Each was an openable state with an upgrade+ /// The five retired generations. Each was an openable state with an upgrade /// path beside it — the relationship data pass for `"4"`, a republication- /// for `"5"` and `"6"`, V8's population pass for `"7"` — and each is now- /// refused, because the population those paths existed for is entirely past- /// them. `"4"`, `"5"` and `"6"` went at `data-model-cleanups` Decision 2;- /// `"7"` joined them here. `multi-site-works` had added it back to- /// `appOpenableMarkerVersions` as the lagging generation V8 upgraded from,- /// and `drop-superseded-columns` **substituted** `"8"` for it (Q2 of that- /// spec) once every device was confirmed past `"7"`, because the lagging- /// row holds one digit at a time.+ /// for `"5"` and `"6"`, V8's population pass for `"7"`, V9's column drop+ /// for `"8"` — and each is now refused, because the population those paths+ /// existed for is entirely past them. `"4"`, `"5"` and `"6"` went at+ /// `data-model-cleanups` Decision 2; `"7"` at `drop-superseded-columns`+ /// (Q2), and `"8"` here (Q18 of `work-and-reading-status`), each time on the+ /// same population precondition, because the lagging row holds one digit at+ /// a time. /// /// The refusal **names the digit**. Nothing else on the failing side of the /// state table distinguishes one retired generation from another, so a /// message that said only "unsupported" would leave the owner of the one /// library this can happen to with nothing to act on. @Test("A marker recording a retired generation is refused, naming the digit",- arguments: ["4", "5", "6", "7"])+ arguments: ["4", "5", "6", "7", "8"]) func retiredMarkerGenerationIsRefused(digit: String) throws { let root = try ClassifierRoot() try root.seedBornAtLiveStore()@@ -287,7 +286,7 @@ struct BootstrapClassifierTests { func belowV5StoreIsRefused() throws { let root = try ClassifierRoot() try V4RecordedStoreFixture.install(at: root.storeURL)- try root.writeMarker("8\n")+ try root.writeMarker("9\n") #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .belowV5(version: "4.0.0"),@@ -302,7 +301,7 @@ struct BootstrapClassifierTests { // it, which is `.indeterminate` — and `.indeterminate` proceeds. try root.createStoreDirectory() try Data("not a database".utf8).write(to: root.storeURL, options: .atomic)- try root.writeMarker("9\n")+ try root.writeMarker("10\n") try #require(StoreMetadata.recordedVersion(at: root.storeURL) == .indeterminate) #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready,@@ -370,7 +369,8 @@ private struct Cell: Sendable, CustomStringConvertible { case .six: try root.writeMarker("6\n") case .eight: try root.writeMarker("8\n") case .nine: try root.writeMarker("9\n")- case .unrecognisedText: try root.writeMarker("10\n")+ case .ten: try root.writeMarker("10\n")+ case .unrecognisedText: try root.writeMarker("99\n") case .nonUTF8: try root.writeMarkerBytes(ClassifierRoot.nonUTF8MarkerBytes) } if historicalMarker { try root.writeHistoricalMarker() }@@ -382,8 +382,8 @@ private struct Cell: Sendable, CustomStringConvertible { func expectedState(recordedVersion: StoreMetadata.RecordedVersion) -> BootstrapState { if case .below(let version) = recordedVersion { return .belowV5(version: version) } let storePresent = family.isStorePresent- if marker == .nine, storePresent { return .ready }- if marker == .eight, storePresent { return .markerLagging(generation: "8") }+ if marker == .ten, storePresent { return .ready }+ if marker == .nine, storePresent { return .markerLagging(generation: "9") } if !storePresent { if marker != .absent { return .orphanedEvidence(kind: .readinessMarker) } if historicalMarker { return .orphanedEvidence(kind: .historicalMarker) }@@ -410,7 +410,7 @@ private enum StoreFamily: String, CaseIterable, Sendable { } private enum MarkerAxis: String, CaseIterable, Sendable {- case absent, four, five, six, eight, nine, unrecognisedText, nonUTF8+ case absent, four, five, six, eight, nine, ten, unrecognisedText, nonUTF8 } /// What the seeded main file is meant to record. The expectation is derived from
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swiftindex 6dfc8a2..b24e2d8 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift@@ -42,7 +42,7 @@ struct AppBootstrapStateTests { // MARK: - Req 2.2: the marker records the current generation and the store is present - @Test("A populated library whose marker records \"8\" validates and opens ready")+ @Test("A populated library whose marker records \"10\" validates and opens ready") func readyMarkerOpensReady() async throws { let root = try LibraryRoot() try await root.seedReadyLibrary(hostname: "r.example")@@ -57,9 +57,9 @@ struct AppBootstrapStateTests { /// The ordered match of the design's state table: evidence overlaps, and the /// first matching predicate wins. A stale historical marker beside a valid- /// `"8"` marker is a *ready* library with a leftover, not an ambiguous state- /// — and the leftover goes after the open, never before it.- @Test("A stale historical marker beside an \"8\" marker resolves to ready and is cleared")+ /// `"10"` marker is a *ready* library with a leftover, not an ambiguous+ /// state — and the leftover goes after the open, never before it.+ @Test("A stale historical marker beside a \"10\" marker resolves to ready and is cleared") func readyMarkerGovernsOverAHistoricalMarker() async throws { let root = try LibraryRoot() try await root.seedReadyLibrary(hostname: "b.example")@@ -79,14 +79,15 @@ struct AppBootstrapStateTests { func unrecognisedMarkerFailsClosed() async throws { let root = try LibraryRoot() try await root.seedReadyLibrary(hostname: "f.example")- // "9" is the version the app publishes, so the unopenable future version- // this pins is the one after it.- try root.writeMarker("10\n")+ // The canonical unrecognised digit (Q28 of `work-and-reading-status`):+ // deliberately far above every generation the app has published, so it+ // keeps testing an unknown marker as the live digit climbs.+ try root.writeMarker("99\n") await #expect(throws: LibraryRepositoryError.self) { try await LibraryRepository.openForApp(root.configuration) }- #expect(try root.markerBytes() == Data("10\n".utf8),+ #expect(try root.markerBytes() == Data("99\n".utf8), "a refused open leaves the marker's bytes alone (Req 2.8)") #expect(root.exists(root.storeURL), "and leaves the store it refused in place") }@@ -182,7 +183,7 @@ struct ExtensionBootstrapStateTests { #expect(result == .ready(oneSite)) } - /// Every state the containing app has not brought to an `"8"` marker, with the+ /// Every state the containing app has not brought to a `"10"` marker, with the /// same assertion over all of them: the open fails, and the library's state is /// byte-identical afterwards apart from the lock file the extension is allowed /// to create.@@ -231,12 +232,14 @@ private enum PreCertificationState: String, CaseIterable, Sendable { case storeWithRetiredMarkerSix /// `configurable-work-types` Req 8.7's update window, with the **live** /// digit: the app has been updated and not yet launched, so the library- /// still records `"8"`. The app opens it — the V8 → V9 stage drops the- /// superseded columns on the way in — validates and republishes at `"9"`;- /// the extension must not, because it holds only a shared lock and the- /// conversion removes columns. The case name is historical: the lagging- /// row holds one digit at a time and `drop-superseded-columns` substituted- /// `"8"` for `"7"` (Q2), which is why the seed below writes `"8"`.+ /// still records `"9"`. The app opens it — the V9 → V10 stage adds the+ /// three defaulted status columns on the way in — validates and republishes+ /// at `"10"`; the extension must not, because it holds only a shared lock+ /// and must never migrate. The case name is historical: the lagging row+ /// holds one digit at a time, and it has been substituted twice since —+ /// `"8"` for `"7"` (Q2 of `drop-superseded-columns`) and `"9"` for `"8"`+ /// (Q18 of `work-and-reading-status`), which is why the seed below writes+ /// `"9"`. case storeWithLaggingMarkerSeven func seed(into root: LibraryRoot) async throws {@@ -254,7 +257,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { try root.removeMarker() try root.writeMigrationArtefact() case .storeWithFutureMarker:- try root.writeMarker("10\n")+ try root.writeMarker("99\n") case .storeWithRetiredMarkerFour: try root.writeMarker("4\n") case .storeWithRetiredMarkerFive:@@ -262,7 +265,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { case .storeWithRetiredMarkerSix: try root.writeMarker("6\n") case .storeWithLaggingMarkerSeven:- try root.writeMarker("8\n")+ try root.writeMarker("9\n") } } }@@ -272,7 +275,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { /// The bytes a certified library's readiness marker holds. Frozen persisted state /// — `FrozenLibraryPathTests` is where that is pinned; here it is the value the /// ready cases compare against.-private let readyMarkerBytes = "9\n"+private let readyMarkerBytes = "10\n" /// The counts of a library seeded with exactly one `Site`. ///@@ -317,7 +320,7 @@ private final class LibraryRoot { // MARK: - Seeding /// The state Req 2.2 is about, reached the way the app reaches it: the- /// app-role opener creates the store, certifies it and marks it `"9"`, and one+ /// app-role opener creates the store, certifies it and marks it `"10"`, and one /// row is written through the repository it returns. No container opener and /// no migration path is involved, so nothing here is removed by a later task. func seedReadyLibrary(hostname: String) async throws {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swiftindex 83d7b28..66c778c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift@@ -16,7 +16,7 @@ import Testing /// /// What is left is the two ends: a store on a retired generation is **refused /// before a container converts it**, and an empty store is marked at birth. The-/// refusal case seeds through `V8RecordedStoreFixture` — a store the container+/// refusal case seeds through `V9RecordedStoreFixture` — a store the container /// *would* open — because a store that could not be opened anyway would prove /// nothing about when the refusal happens. @Suite("Certification paths", .serialized)@@ -44,20 +44,20 @@ struct CertificationPathTests { .trimmingCharacters(in: .whitespacesAndNewlines) } - /// A store recorded at 8.0.0 — the state every installed device is in on- /// the morning of the V9 update, and one the declared V8 → V9 stage converts- /// happily. That it *is* convertible is the point: the refusal below has to- /// come from the marker, before any container exists, not from a store- /// nothing could open.+ /// A store recorded at 9.0.0 — the state every installed device is in on+ /// the morning of the V10 update, and one the declared V9 → V10 stage+ /// converts happily. That it *is* convertible is the point: the refusal+ /// below has to come from the marker, before any container exists, not from+ /// a store nothing could open. /// /// It has been the frozen-snapshot seed at each version in turn, because /// each newly declared stage refuses the generation below it outright and /// the shipped classifier already refused one before any container existed /// (Req 2.9, Decision 1).- private func installStoreArrivedAtV8(_ configuration: LibraryConfiguration) throws {- try V8RecordedStoreFixture.install(at: configuration.storeURL)+ private func installStoreArrivedAtV9(_ configuration: LibraryConfiguration) throws {+ try V9RecordedStoreFixture.install(at: configuration.storeURL) #expect(try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)- == ["8.0.0"], "the seed is written by the frozen snapshot, not the live classes")+ == ["9.0.0"], "the seed is written by the frozen snapshot, not the live classes") } // MARK: - The retired generations@@ -72,20 +72,19 @@ struct CertificationPathTests { /// The refusal happens in `classify`, before `ModelContainer.init`. That is /// what the recorded version proves: this store would have been converted to- /// 9.0.0 by any container construction — **destructively**, since the V8 → V9- /// stage drops columns — and it is still recorded at 8.0.0 afterwards. The- /// marker is left alone for the same reason: the recovery is a backup- /// archive restored over this library, and a refusal that rewrote the- /// evidence would take that away.+ /// 10.0.0 by any container construction, and it is still recorded at 9.0.0+ /// afterwards. The marker is left alone for the same reason: the recovery is+ /// a backup archive restored over this library, and a refusal that rewrote+ /// the evidence would take that away. ///- /// The 8.0.0 pin only means something alongside the control that follows it:- /// the same store, marked `"8"`, opens and is recorded 9.0.0. That is what+ /// The 9.0.0 pin only means something alongside the control that follows it:+ /// the same store, marked `"9"`, opens and is recorded 10.0.0. That is what /// makes the pin an ordering claim rather than a store nothing could convert. @Test("A store on a retired marker generation is refused before anything converts it",- arguments: ["4", "5", "6", "7"])+ arguments: ["4", "5", "6", "7", "8"]) func retiredMarkerGenerationIsRefusedBeforeConversion(digit: String) async throws { let (dir, cfg) = try config()- try installStoreArrivedAtV8(cfg)+ try installStoreArrivedAtV9(cfg) try Data("\(digit)\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) do {@@ -97,29 +96,29 @@ struct CertificationPathTests { } #expect(try markerContent(cfg) == digit, "a refused open may not rewrite the marker")- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"], "the marker check must decide before ModelContainer.init converts anything") // Control, mirroring the extension-side twin // (`MarkerContractTests.extensionDeclinesBeforeOpeningAContainer`):- // with an `"8"` marker the same store is reached, opened and converted.- // Without it the 8.0.0 assertion above could hold because the store was+ // with a `"9"` marker the same store is reached, opened and converted.+ // Without it the 9.0.0 assertion above could hold because the store was // unopenable rather than because the marker was read first.- try Data("8\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+ try Data("9\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) _ = try await LibraryRepository.openForApp(cfg)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["10.0.0"], "the same store converts once the marker check passes") withExtendedLifetime(dir) {} } // MARK: - Mark-at-birth - @Test("Mark-at-birth publishes \"9\" directly for an empty store")+ @Test("Mark-at-birth publishes \"10\" directly for an empty store") func markAtBirthStillPublishesTheCurrentVersionDirectly() async throws { let (dir, cfg) = try config() let (result, _) = try await LibraryRepository.openForApp(cfg) #expect(result == .ready(.zero))- #expect(try markerContent(cfg) == "9",+ #expect(try markerContent(cfg) == "10", "an empty store has nothing to bring forward and is certified at birth (Q26)") withExtendedLifetime(dir) {} }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swiftindex 9a6ea87..123228d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift@@ -296,8 +296,8 @@ struct CharacterConvergenceTests { M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID), ]) - await #expect(throws: BackupV8ExportError.self) {- _ = try await fixture.repository.backupV8Snapshot()+ await #expect(throws: BackupV9ExportError.self) {+ _ = try await fixture.repository.backupV9Snapshot() } withExtendedLifetime(fixture) {} }@@ -379,7 +379,7 @@ struct CharacterCitationRepointingTests { /// The reconciler derives the stamp from the collapsing Entries rather than /// a clock (Q56), so it is routinely *older* than the character it rewrites.- /// `CharacterGroup.modifiedAt` is what `BackupV8Character` carries as its+ /// `CharacterGroup.modifiedAt` is what `BackupV9Character` carries as its /// import value guard, so a backwards stamp would let an archive taken /// before the character's last edit overwrite it. @Test("Repointing never moves a character's modifiedAt backwards")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swiftindex 5559a00..65afab1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift@@ -247,12 +247,12 @@ private final class BlobStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCitationBlob-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftindex a02543a..788f598 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -249,17 +249,17 @@ struct ConvergedRuleGroupValidationTests { // The premise: the store says this library is fine. #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.id == shared) #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname) // The reference validator is what refuses a payload holding one rule // UUID twice, so a decode is the assertion that matters here.- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- let decoded = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ let decoded = try BackupV9Codec.decode(encoded) #expect(decoded.payload.titlePatterns.count == 1) } @@ -289,15 +289,15 @@ struct ConvergedRuleGroupValidationTests { // The premise, again: the store says this library is fine. #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.titlePatterns.count == 1) #expect(payload.titlePatterns.first?.isActive == true) #expect(payload.sites.first?.mode == .taught)- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } /// The URL-rule counterpart, which is the quieter failure: the archive's@@ -316,7 +316,7 @@ struct ConvergedRuleGroupValidationTests { #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.isCurrent == true)@@ -337,15 +337,15 @@ struct ConvergedRuleGroupValidationTests { #expect(await repository.diagnostics.quarantineMap().isEmpty) - let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() #expect(payload.urlRules.count == 1) #expect(payload.urlRules.first?.id == shared) #expect(payload.urlRules.first?.isCurrent == true)- let encoded = try BackupV8Codec.encode(+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+ _ = try BackupV9Codec.decode(encoded) } } @@ -367,12 +367,12 @@ private final class RuleGroupStore { let directory = FileManager.default.temporaryDirectory .appending(path: "AsterismConvergedRules-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) let container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) self.init(context: ModelContext(container)) retained = container
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swiftindex f1a9c00..62901c5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift@@ -244,12 +244,12 @@ private final class ScanStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismCrossSiteScan-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swiftindex 2c37641..663a489 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateWorkloadTests.swift@@ -248,7 +248,8 @@ struct CrossSiteDuplicateWorkloadTests { id: UUID(), basis: basis, draft: WorkMetadataDraft( displayTitle: "A Serial", typeAssignment: .none, genreTags: [],- genericNotes: "reader prose"))+ genericNotes: "reader prose",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) #expect(outcome == .committed) #expect(try await fixture.repository.work(id: Self.first).genericNotes == "reader prose")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swiftindex 246dd9e..6ab4a0c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift@@ -44,12 +44,12 @@ final class DuplicateStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDuplicateReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) seed = ModelContext(container) if let saveStrategy {@@ -219,9 +219,16 @@ final class DuplicateStore { /// /// `afterDerivation` runs between the write phase and the deletion phase, in /// a context of its own — the seam a mid-pass arrival lands through.+ ///+ /// `afterScan` runs between the scan and the write phase, in the pass's own+ /// context, which is the seam an *earlier* arrival lands through: the scan+ /// classified the set over the rows as they were, and the write phase re-reads+ /// them. Nothing in production can order those two, so a value guard that only+ /// holds while the two agree is not a guard at all. @discardableResult func reconcile( batchSize: Int = LibraryRepository.bulkOperationBatchSize,+ afterScan: ((ModelContext) throws -> Void)? = nil, afterDerivation: ((ModelContext) throws -> Void)? = nil ) throws -> DuplicateReconciliationOutcome { let context = ModelContext(container)@@ -229,6 +236,10 @@ final class DuplicateStore { // this harness has no type phase to build it after, so it folds it here. let types = try LibraryRepository.workTypeDirectory(context: context) let scan = try DuplicateScan.run(context: context, ruleRows: nil, types: types)+ if let afterScan {+ try afterScan(context)+ if context.hasChanges { try context.save() }+ } var result = try DuplicateReconciler.run( scan: scan, ledger: &ledger, batchSize: batchSize, context: context, saveStrategy: saveStrategy, types: types)@@ -369,6 +380,9 @@ struct WorkFacts: Equatable, Sendable { let workURLString: String? let genreTags: [String] let workTypeID: UUID?+ let workStatus: WorkStatus+ let readingStatus: ReadingStatus+ let verdict: String let titleProvenance: TitleProvenance let createdAt: Date let modifiedAt: Date@@ -381,6 +395,9 @@ struct WorkFacts: Equatable, Sendable { workURLString = work.primaryMembership?.workURLString genreTags = work.genreTags workTypeID = work.workTypeID+ workStatus = work.workStatus+ readingStatus = work.readingStatus+ verdict = work.verdict titleProvenance = work.titleProvenance createdAt = work.createdAt modifiedAt = work.modifiedAt
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swiftindex f7b0b31..92051c2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift@@ -426,6 +426,66 @@ struct DuplicateReconcilerTests { #expect(entry.modifiedAt == DuplicateStore.epoch.addingTimeInterval(5)) } + /// Q14: a non-default status is reader-authored, so it propagates across a+ /// group exactly as the notes and the tags do.+ @Test("A carrier's non-default statuses propagate to a sibling row")+ func carrierStatusesPropagate() throws {+ let store = try DuplicateStore()+ let site = store.addSite()+ let workID = DuplicateStore.rankedID(1)+ store.addWork(id: workID, title: "The Serial", createdAt: 0, site: site)+ let carrier = store.addWork(id: workID, title: "The Serial", createdAt: 30, site: site)+ carrier.workStatus = .finished+ carrier.readingStatus = .abandoned+ carrier.verdict = "stopped at 40"+ try store.commit()++ try store.reconcileToFixedPoint()++ let works = try store.workFacts()+ #expect(works.count == 2, "a split group's rows are converged, never collapsed")+ #expect(works.allSatisfy { $0.workStatus == .finished })+ #expect(works.allSatisfy { $0.readingStatus == .abandoned })+ #expect(works.allSatisfy { $0.verdict == "stopped at 40" })+ }++ /// The `genreTags` guard, restated for the three V10 columns: propagation+ /// carries a value the reader *set*, so a carrier sitting on the defaults+ /// writes nothing. Without it, a carrier on `reading` would overwrite a+ /// sibling's `abandoned` — the one direction Q14 exists to prevent.+ ///+ /// The sibling's value arrives after the scan classified the set, which is+ /// where the two can legitimately disagree: within one pass the scan would+ /// have called the set divergent and never reached the write.+ @Test("A carrier on the defaults never overwrites a sibling's status or verdict")+ func defaultCarrierNeverOverwritesASiblingStatus() throws {+ let store = try DuplicateStore()+ let site = store.addSite()+ let workID = DuplicateStore.rankedID(1)+ store.addWork(+ id: workID, title: "The Serial", createdAt: 0, notes: "reader notes", site: site)+ store.addWork(id: workID, title: "The Serial", createdAt: 30, site: site)+ try store.commit()++ try store.reconcile(afterScan: { context in+ // The sibling is the row the carrier's notes have not reached yet.+ for row in try context.fetch(FetchDescriptor<Work>())+ where row.genericNotes.isEmpty {+ row.readingStatus = .abandoned+ row.verdict = "not for me"+ }+ })++ let works = try store.workFacts()+ #expect(works.count == 2)+ // The notes still fan out — the guard is per field, not a veto on the+ // whole write.+ #expect(works.allSatisfy { $0.genericNotes == "reader notes" })+ let kept = try #require(works.first { $0.readingStatus == .abandoned })+ #expect(kept.verdict == "not for me")+ #expect(kept.workStatus == .ongoing)+ }+ @Test("No Entry becomes unattached through a Work collapse") func noEntryIsUnattachedByAWorkCollapse() throws { let store = try DuplicateStore()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swiftindex bcddaa6..40410cf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift@@ -408,6 +408,92 @@ struct DuplicateResolutionTests { #expect(survivor.workTypeID == ResolutionSeedStore.typeID(for: .toon)) } + /// V10 Req 7.2: a status the reader moved off its default and a verdict they+ /// typed are authored content, so the sheet has to *name* them among the+ /// fields the copies differ in — a set torn by nothing but a status raised a+ /// review card listing no differing field until this landed.+ ///+ /// The second variant's verdict sits under a `reading` status, which Q7+ /// hides on the detail screen; Q21 shows it here, because without it the two+ /// copies would look identical in the one place the reader has to tell them+ /// apart.+ @Test("The Work sheet names a differing status and verdict, and shows a hidden verdict")+ func workVariantsCarryTheStatusesAndVerdict() async throws {+ let library = try ResolutionFixture()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ store.insertWork(+ title: "Serial", offset: 0, notes: "same notes",+ workStatus: .finished, readingStatus: .finished, verdict: "a fine ending")+ store.insertWork(+ title: "Serial", offset: 40, notes: "same notes",+ workStatus: .hiatus, readingStatus: .reading, verdict: "put it down")+ }+ let repository = try await library.openForApp()+ let setKey = try Self.onlyWorkSetKey(library)++ let contract = try await repository.projectDuplicateResolution(setKey: setKey)++ guard case .work(_, let variants, let fields, _) = contract else {+ Issue.record("expected a Work contract")+ return+ }+ #expect(fields == [.genericNotes, .workStatus, .readingStatus, .verdict])+ #expect(variants.map(\.workStatus) == [.finished, .hiatus])+ #expect(variants.map(\.readingStatus) == [.finished, .reading])+ #expect(variants.map(\.verdict) == ["a fine ending", "put it down"])+ }++ /// Req 7.2's second half: resolving writes the chosen variant's three to the+ /// survivor, and Q41's — the losing variant's verdict is reader text, so it+ /// lands in the survivor's notes under the same audit block its notes do.+ ///+ /// The survivor is seeded **twice under one id** — a split Work group, the+ /// shape Req 3.2 makes ordinary — because the three values are written in+ /// the commit's survivor-row loop. A read-back of `first` would pass on a+ /// write that reached one row of the group and left the other holding the+ /// old status.+ @Test("A Work resolution writes the chosen statuses and records the losing verdict")+ func workResolutionCarriesTheStatusesAndVerdict() async throws {+ let library = try ResolutionFixture()+ let survivorID = UUID()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ store.insertWork(+ id: survivorID, title: "Serial", offset: 0, notes: "kept notes",+ workStatus: .finished, readingStatus: .finished,+ verdict: "a fine ending")+ store.insertWork(+ id: survivorID, title: "Serial", offset: 10, notes: "kept notes",+ workStatus: .finished, readingStatus: .finished,+ verdict: "a fine ending")+ store.insertWork(+ title: "Serial", offset: 40, notes: "chosen notes",+ workStatus: .hiatus, readingStatus: .abandoned,+ verdict: "gave up in book two")+ }+ let repository = try await library.openForApp()+ let setKey = try Self.onlyWorkSetKey(library)+ let contract = try await repository.projectDuplicateResolution(setKey: setKey)+ // The later variant, so the survivor is a row that does not already hold+ // what the write is supposed to land.+ let chosen = try #require(contract.variantIDs.last)++ #expect(+ try await repository.commitDuplicateResolution(+ contract, choosing: chosen, appendingOtherNotes: false)+ == .committed(survivorID: survivorID))++ let survivorRows = try library.workRows().filter { $0.id == survivorID }+ #expect(survivorRows.count == 2)+ #expect(survivorRows.allSatisfy { $0.workStatus == .hiatus })+ #expect(survivorRows.allSatisfy { $0.readingStatus == .abandoned })+ #expect(survivorRows.allSatisfy { $0.verdict == "gave up in book two" })+ #expect(survivorRows.allSatisfy { $0.genericNotes.hasPrefix("chosen notes") })+ #expect(survivorRows.allSatisfy { $0.genericNotes.contains("Verdict: a fine ending") })+ #expect(survivorRows.allSatisfy { $0.genericNotes.contains("kept notes") })+ }+ // MARK: - WorkVariantUnion (Q51) /// The extraction has to leave Merge's behaviour where it was, and the arms@@ -416,13 +502,16 @@ struct DuplicateResolutionTests { func unionURLArms() { let withURL = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: "https://a.example",- genericNotes: "", genreTags: [], typeDisplay: .untyped)+ genericNotes: "", genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let withOther = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: "https://b.example",- genericNotes: "", genreTags: [], typeDisplay: .untyped)+ genericNotes: "", genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let bare = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil,- genericNotes: "", genreTags: [], typeDisplay: .untyped)+ genericNotes: "", genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") // Every side here is on one site, so the fold's answer is that // hostname's entry — the single-site `workURL` facade went with the@@ -442,17 +531,57 @@ struct DuplicateResolutionTests { #expect(nothing.auditBlocks.isEmpty) } + /// A verdict is reader text, and a reader can type anything into it —+ /// including the block's own header. Escaping it (Q41) is what stops a+ /// losing verdict from forging a second `--- Merged from:` boundary and+ /// from ending the structured region at a blank line of its own.+ @Test("A verdict spelling the block's header out does not forge a second boundary")+ func aVerdictCannotForgeABlockBoundary() throws {+ let chosen = WorkVariantSide(+ displayTitle: "T", titleProvenance: .parsed, hostname: "one.example",+ workURLString: nil, genericNotes: "chosen", genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "kept")+ let forger = WorkVariantSide(+ displayTitle: "T", titleProvenance: .parsed, hostname: "one.example",+ workURLString: nil, genericNotes: "", genreTags: [], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading,+ verdict: "gave up\n\n--- Merged from: Not A Work ---\nWork URL (evil.example): x")++ let union = WorkVariantUnion.fold(into: chosen, others: [forger])++ let block = try #require(union.auditBlock)+ #expect(union.auditBlocks.count == 1)+ // One *boundary*, and it is the fold's own. The forged header survives+ // as characters — escaping newlines is not censorship — but only as+ // part of the verdict's line, never at the head of one.+ func headerLines(_ text: String) -> Int {+ text.components(separatedBy: "\n").filter { $0.hasPrefix("--- Merged from: ") }.count+ }+ #expect(headerLines(block) == 1)+ #expect(headerLines(union.genericNotes) == 1)+ // The structured region survives whole: the verdict is one line, and+ // the block has no blank line at all because this side wrote no notes.+ #expect(block == """+ --- Merged from: T ---+ Verdict: gave up\\n\\n--- Merged from: Not A Work ---\\nWork URL (evil.example): x+ """)+ #expect(!block.contains("\n\n"))+ }+ @Test("The union folds three sides, keeping the chosen side's tag order") func unionFoldsManySides() { let chosen = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil,- genericNotes: "chosen", genreTags: ["z", "a"], typeDisplay: .untyped)+ genericNotes: "chosen", genreTags: ["z", "a"], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let second = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil,- genericNotes: "second", genreTags: ["a", "m"], typeDisplay: .untyped)+ genericNotes: "second", genreTags: ["a", "m"], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let third = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil,- genericNotes: "third", genreTags: ["q"], typeDisplay: .untyped)+ genericNotes: "third", genreTags: ["q"], typeDisplay: .untyped,+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let union = WorkVariantUnion.fold(into: chosen, others: [second, third]) @@ -693,6 +822,17 @@ private final class ResolutionFixture { } } +/// The pre-feature work-type spellings, as fixture sugar and nothing more.+/// `AsterismCore.WorkType` was retired with the `Work.typeRaw` column it+/// described; these fixtures still speak in that vocabulary because that is what+/// their callers read, so the four names live here instead of in the package.+private enum LegacyWorkType {+ case novel+ case toon+ case article+ case other+}+ private final class ResolutionSeedStore { let context: ModelContext @@ -756,7 +896,7 @@ private final class ResolutionSeedStore { /// A stable work-type identity per pre-feature spelling, so a fixture that /// says "toon" seeds a type the V8 derivation can see. `.other` is untyped, /// which is what it always meant.- static func typeID(for type: WorkType) -> UUID? {+ static func typeID(for type: LegacyWorkType) -> UUID? { switch type { case .other: nil case .novel: UUID(uuidString: "0E7A0000-0000-4000-8000-00000000010A")!@@ -768,7 +908,9 @@ private final class ResolutionSeedStore { @discardableResult func insertWork( id: UUID = UUID(), title: String, offset: TimeInterval, notes: String = "",- tags: [String] = [], workURL: String? = nil, type: WorkType = .other+ tags: [String] = [], workURL: String? = nil, type: LegacyWorkType = .other,+ workStatus: WorkStatus = .ongoing, readingStatus: ReadingStatus = .reading,+ verdict: String = "" ) -> Work { // The fixture still speaks in the pre-feature vocabulary because that is // what its callers read, but V8 derives a type from the work-type@@ -787,6 +929,11 @@ private final class ResolutionSeedStore { work.primaryMembershipEdit { $0.workURLString = workURL } work.membershipValues.first?.workURLString = workURL work.workTypeID = ResolutionSeedStore.typeID(for: type)+ // V10 (Q14): a non-default status or a non-empty verdict is authored+ // content, so a fixture seeding one is seeding a variant.+ work.workStatus = workStatus+ work.readingStatus = readingStatus+ work.verdict = verdict work.modifiedAt = ResolutionFixture.epoch.addingTimeInterval(offset) return work }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swiftindex 4dcee89..d08335b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift@@ -516,12 +516,12 @@ private final class ScanStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismDuplicateScan-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swiftindex 266350c..7bc85b2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift@@ -145,6 +145,41 @@ struct EnumTolerancePolicyTests { #expect(contract.basis.identity.state == .none) } + // MARK: - V10's two status columns++ /// Reqs 1.3 and 2.7: an unknown *and* an empty spelling both read as the+ /// column's default, through each accessor.+ ///+ /// The empty string is the case worth naming. It is not a value any writer+ /// produces — the column is defaulted and non-optional — but it is what a+ /// CloudKit record arriving without the field can materialise as, and+ /// `ToleratedEnum.read` has to answer for it the same way it answers for a+ /// newer build's spelling. Writing is unaffected (Q19): the setter puts the+ /// picked value on the raw column whatever was there before.+ @Test("Unknown and empty status raws read as the defaults, and a write goes through")+ func unknownStatusRawsReadAsTheDefaults() throws {+ let work = Work(displayTitle: "A Serial", timestamp: Date(timeIntervalSince1970: 0))++ work.workStatusRaw = "serialised-by-committee"+ work.readingStatusRaw = "skimming"+ #expect(work.workStatus == .ongoing)+ #expect(work.readingStatus == .reading)++ work.workStatusRaw = ""+ work.readingStatusRaw = ""+ #expect(work.workStatus == .ongoing)+ #expect(work.readingStatus == .reading)++ // Tolerance is read-only. A commit writes what the picker showed, which+ // is the raw spelling of the value the accessor was set to.+ work.workStatus = .hiatus+ work.readingStatus = .abandoned+ #expect(work.workStatusRaw == "hiatus")+ #expect(work.readingStatusRaw == "abandoned")+ #expect(work.workStatus == .hiatus)+ #expect(work.readingStatus == .abandoned)+ }+ // MARK: - What tolerance deliberately does not reach /// Q8. The row the Works screen now reads happily is still refused by the@@ -172,9 +207,9 @@ struct EnumTolerancePolicyTests { #expect(snapshot.works.contains { $0.id == workID }) do {- _ = try await repository.backupV8Snapshot()+ _ = try await repository.backupV9Snapshot() Issue.record("the export archived an unrepresentable value")- } catch let error as BackupV8ExportError {+ } catch let error as BackupV9ExportError { guard case .unrepresentableValue(let record, let field, let value) = error else { Issue.record("expected .unrepresentableValue, got \(error)") return@@ -211,9 +246,9 @@ struct EnumTolerancePolicyTests { let repository = try await library.openForApp() do {- _ = try await repository.backupV8Snapshot()+ _ = try await repository.backupV9Snapshot() Issue.record("the export archived an unreadable citation blob")- } catch let error as BackupV8ExportError {+ } catch let error as BackupV9ExportError { guard case .unrepresentableValue(_, let refused, _) = error else { Issue.record("expected .unrepresentableValue, got \(error)") return
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swiftindex da9f465..dc1b72c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift@@ -268,7 +268,9 @@ struct FanOutWriteTests { draft: WorkMetadataDraft( displayTitle: "Renamed", typeAssignment: .configured(Self.fannedTypeID), genreTags: ["fantasy"],- genericNotes: "reader prose"))+ genericNotes: "reader prose",+ workStatus: .finished, readingStatus: .finished,+ verdict: " a fine ending ")) #expect(outcome == .committed) let rows = try library.workRows(id: workID)@@ -277,6 +279,65 @@ struct FanOutWriteTests { #expect(rows.allSatisfy { $0.genericNotes == "reader prose" }) #expect(rows.allSatisfy { $0.genreTags == ["fantasy"] }) #expect(rows.allSatisfy { $0.workTypeID == Self.fannedTypeID })+ // Req 7.1: all three land on **every** row, and Q33 trims the verdict+ // here and nowhere else.+ #expect(rows.allSatisfy { $0.workStatus == .finished })+ #expect(rows.allSatisfy { $0.readingStatus == .finished })+ #expect(rows.allSatisfy { $0.verdict == "a fine ending" })+ }++ /// Q33's trim is what keeps a verdict of nothing but spaces from counting as+ /// authored content: `isBare` tests `verdict.isEmpty`, so an untrimmed+ /// `" "` would make the Work non-bare for good and take silent duplicate+ /// resolution off it for a value the reader cannot see.+ @Test("A whitespace-only verdict trims to empty and leaves the Work bare")+ func whitespaceOnlyVerdictLeavesTheWorkBare() async throws {+ let library = try WriteFixture()+ let workID = UUID()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+ }+ let repository = try await library.openForApp()++ let outcome = try await repository.updateWork(+ id: workID, basis: try library.workBasis(id: workID),+ draft: WorkMetadataDraft(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [],+ genericNotes: "",+ workStatus: .ongoing, readingStatus: .reading, verdict: " "))++ #expect(outcome == .committed)+ let updated = try await repository.work(id: workID)+ #expect(updated.verdict == "")+ let rows = try library.workRows(id: workID)+ #expect(rows.allSatisfy { GroupOrdering.authoredContent(of: $0, types: .empty).isBare })+ }++ /// The split arm of `snapshot(_ group:)` presents the **carrier's** authored+ /// content, and the three V10 columns are authored content (Q14) — so a+ /// split group whose one non-bare row carries a status presents that status,+ /// not the representative's default.+ @Test("A split Work group presents the carrier's statuses and verdict")+ func splitWorkGroupPresentsTheCarriersStatuses() async throws {+ let library = try WriteFixture()+ let workID = UUID()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ store.insertWork(id: workID, hostname: "dup.example", title: "A Serial", offset: 0)+ let carrier = store.insertWork(+ id: workID, hostname: "dup.example", title: "A Serial", offset: 30)+ carrier.workStatus = .hiatus+ carrier.readingStatus = .abandoned+ carrier.verdict = "stalled"+ }+ let repository = try await library.openForApp()++ let work = try await repository.work(id: workID)++ #expect(work.workStatus == .hiatus)+ #expect(work.readingStatus == .abandoned)+ #expect(work.verdict == "stalled") } @Test("updateWork refuses a torn Work group")@@ -300,7 +361,9 @@ struct FanOutWriteTests { displayTitle: "A Serial", typeAssignment: .none, genreTags: [], genericNotes: "device one", memberships: [WorkMembershipBasis(hostname: "dup.example", urlIdentity: nil)], lastParsedTitle: "A Serial", titleProvenance: .parsed), draft: WorkMetadataDraft(- displayTitle: "Renamed", typeAssignment: .none, genreTags: [], genericNotes: "overwrite"))+ displayTitle: "Renamed", typeAssignment: .none, genreTags: [],+ genericNotes: "overwrite",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) guard case .conflict(.torn) = outcome else { Issue.record("expected a torn conflict, got \(outcome)")@@ -578,7 +641,7 @@ final class WriteFixture { /// identity key — the upsert shape (Decision 8), so the plan updates rather /// than inserts. func importPlan(entryID: UUID, note: String, modifiedAt: Date) throws -> BackupImportPlan {- let entry = BackupV8Entry(+ let entry = BackupV9Entry( id: entryID, captureTitle: "Chapter", captureTitleSource: .host, rawURL: identityKey, canonicalURL: nil, hostname: "dup.example", entryIdentityKey: identityKey,@@ -587,7 +650,7 @@ final class WriteFixture { note: note, rating: nil, firstCapturedAt: Self.epoch, lastSharedAt: Self.epoch, modifiedAt: modifiedAt, workID: nil, intentionallyUnattached: false, citations: EntryCitations())- let site = BackupV8Site(+ let site = BackupV9Site( hostname: "dup.example", displayName: "Dup", mode: .untaught, junkSuffixRule: nil) let payload = BackupImportPayload( entries: [entry], works: [], sites: [site], titlePatterns: [], urlRules: [])
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swiftindex f8e9fe6..e06eb67 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift@@ -29,8 +29,8 @@ import Testing /// extension is greyed out and cannot be selected on the device. The exporter's /// own filenames are `Asterism-backup-v4-<timestamp>.json` for the same reason. ///-/// The archive is produced through the real `BackupV8Exporter` — the same-/// `backupV8Snapshot()` → `BackupV8Codec.encode` → decode-validate → write path+/// The archive is produced through the real `BackupV9Exporter` — the same+/// `backupV9Snapshot()` → `BackupV9Codec.encode` → decode-validate → write path /// the app's Settings export uses — so what lands on disk is byte-for-byte the /// kind of file the app produces, checksum and all. The generator then re-reads /// the written file through `BackupImporter.plan(from:)`, which is the same@@ -96,9 +96,9 @@ struct FixtureArchiveGeneratorTests { // it just produced. It picks its own filename in the staging directory; // the archive is moved to `destination` afterwards. let staging = root.appending(path: "staging", directoryHint: .isDirectory)- let exporter = BackupV8Exporter(repository: repository, stagingDirectory: staging)+ let exporter = BackupV9Exporter(repository: repository, stagingDirectory: staging) let result = try await exporter.export(- metadata: BackupV8Metadata(appBuild: "fixture-5k", exportedAt: exportedAt))+ metadata: BackupV9Metadata(appBuild: "fixture-5k", exportedAt: exportedAt)) withExtendedLifetime(container) {} try FileManager.default.createDirectory(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.jsondeleted file mode 100644index 9836439..0000000--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.json+++ /dev/null@@ -1 +0,0 @@-{"appBuild":"golden","backupFormatVersion":8,"capabilityGate":"multi-site","checksum":"851980b9e9bddafcb0b7281999e12176cd29ae8548f9989c12d22d95b9d21c9e","databaseSchemaVersion":9,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"manual"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","titleProvenance":"parsed","typeName":"novel","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-9-10-golden.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-9-10-golden.jsonnew file mode 100644index 0000000..711120c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-9-10-golden.json@@ -0,0 +1 @@+{"appBuild":"golden","backupFormatVersion":9,"capabilityGate":"multi-site","checksum":"dd0a2284a0dc1a2cb576ecaa0e78017f016a7eccab424d857c140ef861a1d01e","databaseSchemaVersion":10,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"finished","titleProvenance":"manual","typeName":"novel","verdict":"","workStatus":"finished","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"abandoned","titleProvenance":"parsed","typeName":"novel","verdict":"Stalled three years in; I gave up waiting.","workStatus":"hiatus","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swiftindex 5c92182..6cab596 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift@@ -74,9 +74,9 @@ struct FrozenLibraryPathTests { /// /// The *digit* advances with each marker generation — `configurable-work-types` /// moved it from `"5"` to `"6"` (Q26), `character-extraction` from `"6"` to- /// `"7"` (Q80) — and the app opens every generation it has published. What is- /// frozen is the shape and the filename beside it.- private static let markerContents = "9\n"+ /// `"7"` (Q80), and it now reads `"10"`, the first two-character+ /// generation. What is frozen is the shape and the filename beside it.+ private static let markerContents = "10\n" /// Everything a fresh app-role open is allowed to leave in the root, SQLite's /// own `-wal`/`-shm` companions excluded. An extra entry here is a path@@ -279,15 +279,15 @@ struct FrozenLibraryPathTests { /// name here — see the rule in the suite's doc comment. @Test("No declared identifier carries a version number it does not describe") func noIdentifierNamesAVersionItDoesNotDescribe() throws {- /// The store schemas this package declares — V9 live and V8 frozen as+ /// The store schemas this package declares — V10 live and V9 frozen as /// the `from` version of the one lightweight stage — the plan that /// stages them, and the floor the recorded-version reading refuses /// below. ///- /// V5, V6 and V7 went with the stages that named them: every device is- /// confirmed at marker `"8"`, which is `retire-migration-chain`+ /// V5, V6, V7 and V8 went with the stages that named them: every device+ /// is confirmed at marker `"9"`, which is `retire-migration-chain` /// Decision 6's population precondition for each of them (Q2 of- /// `drop-superseded-columns`).+ /// `drop-superseded-columns`, Q18 of `work-and-reading-status`). /// /// **No marker generation is named here any more.** `markerLaggingV4`, /// `markerLaggingV5` and `markerLaggingV6` were the bootstrap states for@@ -297,17 +297,17 @@ struct FrozenLibraryPathTests { /// both deliberately unversioned by name because they always mean the /// current generation. let declaresAStoreSchemaOrMarkerGeneration: Set<String> = [- "AsterismSchemaV8", "AsterismSchemaV9", "AsterismV9MigrationPlan",+ "AsterismSchemaV9", "AsterismSchemaV10", "AsterismV10MigrationPlan", "atOrAboveV5", "belowV5", "firstV5Major", ]- /// The archive format — 8/9, the one shape the app reads and writes, plus+ /// The archive format — 9/10, the one shape the app reads and writes, plus /// the 2/2 URL-rule origin the store still names. These name a /// serialization version, not a store schema, and they are accurate:- /// `rule-citation-by-uuid` Q9 mints format 8 over schema 9, and every- /// record this generation carries is its own (Q14) rather than one an+ /// `work-and-reading-status` Q17 mints format 9 over schema 10, and every+ /// record this generation carries is its own (Q34) rather than one an /// earlier generation froze. ///- /// Every earlier generation's **read and write path** is gone, 7/8+ /// Every earlier generation's **read and write path** is gone, 8/9 /// included, so every name that described one — the codecs, documents, /// payloads, exporters, snapshot protocols, reference and shape /// validators, per-generation planners, gates and materializers — is@@ -319,18 +319,18 @@ struct FrozenLibraryPathTests { /// single format, and a digit in their names would be a digit describing /// nothing. let namesTheArchiveFormat: Set<String> = [- "BackupV8Entry", "BackupV8Site", "BackupV8TitlePattern", "BackupV8URLRule",- "BackupV8Work", "BackupV8WorkType", "BackupV8Membership", "BackupV8DistinctPair",- "BackupV8Character", "BackupV8Codec",- "BackupV8Document", "BackupV8ExportError", "BackupV8Exporter", "BackupV8Metadata",- "BackupV8Payload", "BackupV8ReferenceValidator",- "BackupV8SnapshotProviding", "BackupV8Suppression",- "backupV8Snapshot",+ "BackupV9Entry", "BackupV9Site", "BackupV9TitlePattern", "BackupV9URLRule",+ "BackupV9Work", "BackupV9WorkType", "BackupV9Membership", "BackupV9DistinctPair",+ "BackupV9Character", "BackupV9Codec",+ "BackupV9Document", "BackupV9ExportError", "BackupV9Exporter", "BackupV9Metadata",+ "BackupV9Payload", "BackupV9ReferenceValidator",+ "BackupV9SnapshotProviding", "BackupV9Suppression",+ "backupV9Snapshot", "importedV2", "importedV2Path",- "mapV8EntryRecord", "mapV8SiteRecord", "mapV8TitlePatternRecord",- "mapV8URLRuleRecord", "mapV8WorkRecord",- "mapV8CharacterRecord", "mapV8SuppressionRecord",- "projectV8Payload",+ "mapV9EntryRecord", "mapV9SiteRecord", "mapV9TitlePatternRecord",+ "mapV9URLRuleRecord", "mapV9WorkRecord",+ "mapV9CharacterRecord", "mapV9SuppressionRecord",+ "projectV9Payload", ] /// The Entry identity-key generation, `EntryIdentityKeyV2Codec` / /// `V3Codec`. A v2 key and a v3 key are different encodings of the same@@ -379,27 +379,26 @@ struct FrozenLibraryPathTests { .map { String($0.1) } } #expect(- declared.sorted() == ["AsterismSchemaV8", "AsterismSchemaV9"],+ declared.sorted() == ["AsterismSchemaV10", "AsterismSchemaV9"], "the package declares versioned schemas \(declared); Req 3.3 allows only ones a plan references") - let referenced = AsterismV9MigrationPlan.schemas.map { String(describing: $0) }+ let referenced = AsterismV10MigrationPlan.schemas.map { String(describing: $0) } #expect(- referenced == ["AsterismSchemaV8", "AsterismSchemaV9"],+ referenced == ["AsterismSchemaV9", "AsterismSchemaV10"], "the plan references \(referenced), which is not the set of declared schemas")- // One lightweight stage. It is the first that **removes** — 35- // attributes and the `Work.site` ↔ `Site.works` inverse pair — which is- // why every reader had to be off them before this schema existed:- // `ModelContainer.init` runs the conversion and nothing gets to read- // what it destroys. The V5, V6 and V7 stages retired with the snapshots- // they named (Q2), on the population precondition every device now meets.+ // One lightweight stage, and this one purely **adds** — three defaulted+ // `Work` columns, filled from the attribute defaults inside+ // `ModelContainer.init` with no data pass behind them. The V8 stage+ // retired with the snapshot it named (Q18), on the population+ // precondition every device now meets. #expect(- AsterismV9MigrationPlan.stages.count == 1,- "the plan stages \(AsterismV9MigrationPlan.stages.count) migrations; V8 → V9 is one")- #expect(- AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0),- "the frozen snapshot's version stamp is the `from` side every V8 store is matched on")+ AsterismV10MigrationPlan.stages.count == 1,+ "the plan stages \(AsterismV10MigrationPlan.stages.count) migrations; V9 → V10 is one") #expect( AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0),+ "the frozen snapshot's version stamp is the `from` side every V9 store is matched on")+ #expect(+ AsterismSchemaV10.versionIdentifier == Schema.Version(10, 0, 0), "the live schema's version stamp is what every recorded store is compared against") } @@ -569,6 +568,13 @@ struct FrozenLibraryPathTests { "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7", "AsterismV8MigrationPlan", "V8PopulationPass", "LegacyColumns", "V8Shape",+ // Retired by `work-and-reading-status` (T-2306) with the V8 → V9+ // stage: every device is confirmed at marker `"9"` (Q18), so the+ // snapshot has no store left to be the `from` side of. `WorkType`+ // went with them — the closed pre-feature type set described+ // `Work.typeRaw`, dropped at V9, and its last referent was the+ // frozen V8 snapshot.+ "AsterismSchemaV8", "AsterismV9MigrationPlan", "WorkType", ] for file in try coreSourceFiles() { let text = try String(contentsOf: file, encoding: .utf8)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swiftindex 80612b9..73a0425 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift@@ -190,6 +190,44 @@ struct GroupFetchTests { #expect(group.authoredContent?.genericNotes == "reader prose") } + /// Req 7.2: a status is authored content, so a bare row and a row carrying a+ /// non-default one are the mixed shape — one variant, presented by the row+ /// that holds it — and two rows disagreeing on a status are two variants.+ @Test("A Work group reports a non-default status as its authored content")+ func workGroupCarriesStatuses() throws {+ let store = try GroupStore()+ let id = UUID()+ store.addWork(id: id, title: "A Serial")+ let authored = store.addWork(id: id, title: "A Serial", offset: 60)+ authored.readingStatus = .abandoned+ authored.verdict = " gave up "+ try store.commit()++ let group = try LibraryRepository.fetchWorkGroup(id: id, context: store.context)++ #expect(group.isTorn == false)+ #expect(group.authoredContent?.readingStatus == .abandoned)+ #expect(group.authoredContent?.workStatus == .ongoing)+ #expect(group.authoredContent?.verdict == " gave up ")+ #expect(group.carrier === authored)+ }++ @Test("A Work group whose rows disagree on a status is torn")+ func tornWorkGroupOnStatus() throws {+ let store = try GroupStore()+ let id = UUID()+ let first = store.addWork(id: id, title: "A Serial")+ first.workStatus = .finished+ let second = store.addWork(id: id, title: "A Serial", offset: 60)+ second.workStatus = .hiatus+ try store.commit()++ let group = try LibraryRepository.fetchWorkGroup(id: id, context: store.context)++ #expect(group.isTorn)+ #expect(group.variants.count == 2)+ }+ @Test("A Work group whose rows disagree is torn") func tornWorkGroup() throws { let store = try GroupStore()@@ -222,12 +260,12 @@ private final class GroupStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismGroupFetch-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swiftindex 65b0e52..754d429 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -394,6 +394,67 @@ struct GroupOrderingTests { #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare == false) } + /// Q14: a non-default status and a non-empty verdict are reader-authored, so+ /// a row carrying one can never be collapsed away silently. The defaults are+ /// the *absence* of an authored value, exactly as `.none` is for the type.+ @Test("A Work is bare only at both status defaults and an empty verdict")+ func statusAuthorship() throws {+ let store = try OrderingStore()+ let work = store.addWork(title: "A Work", offset: 0)+ // Q34: a Work reads as bare only with a parsed title matching its+ // display title, or the title alone would carry the bareness.+ work.lastParsedTitle = "A Work"+ try store.commit()++ #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare)+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).workStatus == .ongoing)+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).readingStatus == .reading)+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).verdict == "")++ work.workStatus = .hiatus+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).workStatus == .hiatus)+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare == false)++ work.workStatus = .ongoing+ work.readingStatus = .abandoned+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).readingStatus == .abandoned)+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare == false)++ work.readingStatus = .reading+ work.verdict = "worth it"+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).verdict == "worth it")+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare == false)++ work.verdict = ""+ #expect(GroupOrdering.authoredContent(of: work, types: .empty).isBare)+ }++ /// Req 7.2: each of the three has to reach `orderComponents`, or two rows+ /// differing only on one of them would hash to a single variant and one+ /// reader's value would be lost to the other's.+ @Test("Each status field changes a Work's order components")+ func statusOrderComponents() {+ let bare = WorkAuthoredContent()+ let tokens = { (content: WorkAuthoredContent) in+ content.orderComponents.map(\.canonicalToken)+ }++ #expect(tokens(WorkAuthoredContent(workStatus: .hiatus)) != tokens(bare))+ #expect(tokens(WorkAuthoredContent(workStatus: .finished))+ != tokens(WorkAuthoredContent(workStatus: .hiatus)))+ #expect(tokens(WorkAuthoredContent(readingStatus: .abandoned)) != tokens(bare))+ #expect(tokens(WorkAuthoredContent(readingStatus: .finished))+ != tokens(WorkAuthoredContent(readingStatus: .abandoned)))+ #expect(tokens(WorkAuthoredContent(verdict: "a verdict")) != tokens(bare))+ // The two statuses occupy distinct slots: a work `finished` reads+ // differently from a reading `finished`.+ #expect(tokens(WorkAuthoredContent(workStatus: .finished))+ != tokens(WorkAuthoredContent(readingStatus: .finished)))+ #expect(+ VariantID(components: WorkAuthoredContent(verdict: "a").orderComponents)+ != VariantID(components: WorkAuthoredContent(verdict: "b").orderComponents))+ }+ @Test("Genre tag order does not make two Works disagree") func genreTagsAreOrderInsensitive() throws { let store = try OrderingStore()@@ -573,12 +634,12 @@ private final class OrderingStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismGroupOrdering-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swiftindex f9f679f..04dac9b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift@@ -284,12 +284,12 @@ private final class ResolutionStore { } private static func makeContainer(at directory: URL) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) return try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swiftindex 14cafc1..0725b3d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift@@ -350,7 +350,14 @@ enum LibraryGraphSerializer { "# rule-citation-by-uuid (T-2281) cites a rule by UUID alone, so no citation", "# carries a version. Re-recorded by deleting that key from the one blob that", "# held it, not by regenerating the file.",- "format 6",+ "# format 7 is schema V10 (work-and-reading-status, T-2306): every work line",+ "# gains workStatusRaw, readingStatusRaw and verdict after titleProvenanceRaw.",+ "# The seed sets none of them, so every work carries the V9 -> V10 stage's",+ "# defaults — which is the point: the three fields on each line are the",+ "# baseline's own statement that a converted row reads ongoing/reading/empty.",+ "# Re-recorded by adding those three fields to each work line and reviewing",+ "# the diff line by line (Q35), not by regenerating the file.",+ "format 7", "counts entries=\(entries.count) works=\(works.count) sites=\(sites.count) " + "titlePatterns=\(patterns.count) urlRulePatterns=\(rules.count) " + "workTypes=\(workTypes.count) memberships=\(memberships.count) "@@ -419,6 +426,9 @@ enum LibraryGraphSerializer { ("workTypeID", optional(work.workTypeID?.uuidString)), ("genreTags", "[" + work.genreTags.map(quoted).joined(separator: ",") + "]"), ("titleProvenanceRaw", quoted(work.titleProvenanceRaw)),+ ("workStatusRaw", quoted(work.workStatusRaw)),+ ("readingStatusRaw", quoted(work.readingStatusRaw)),+ ("verdict", quoted(work.verdict)), ("createdAt", timestamp(work.createdAt)), ("modifiedAt", timestamp(work.modifiedAt)), ]))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swiftindex d4a0411..fe0ef7c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift@@ -367,12 +367,12 @@ private final class ToleranceScanStore { } private static func makeContainer(at directory: URL) throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) return try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swiftindex 8bc1c73..4bd8319 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift@@ -447,12 +447,12 @@ private final class ValidatorStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismValidatorTolerance-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swiftindex 24e914c..3da58de 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift@@ -357,7 +357,7 @@ private struct LookupCaptureFixture { _ seed: (ModelContext, String) -> Void ) throws -> LookupCaptureFixture { let url = "https://example.com/chapter-1"- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let container = try ModelContainer( for: schema, configurations: [
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swiftindex 0e23e51..c563a46 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift@@ -140,7 +140,7 @@ struct M4BulkChunkPerformanceTests { /// hostname, every one of them carrying the Site relationship whose assignment /// is the cost being measured. private enum M4ChunkFixture {- static func exportedFixturePayload() async throws -> BackupV8Payload {+ static func exportedFixturePayload() async throws -> BackupV9Payload { let root = FileManager.default.temporaryDirectory .appending( path: "asterism-m4-chunk-source-\(UUID().uuidString)", directoryHint: .isDirectory)@@ -154,7 +154,7 @@ private enum M4ChunkFixture { let repository = LibraryRepository.makeRepository( configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy()) try await repository.seedM4PerformanceFixture()- let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() withExtendedLifetime(container) {} return payload }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swiftindex 309a1e1..edc9df4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift@@ -315,7 +315,7 @@ struct M4DuplicateScalePerformanceTests { /// records what the projection alone costs so the claim is a reading rather /// than an argument. ///- /// The *projection* is timed, not `BackupV8Exporter.export`: the encode,+ /// The *projection* is timed, not `BackupV9Exporter.export`: the encode, /// the decode-validation and the file write dominate and none of them /// changed. @Test("Backup projection over a duplicate-free library (Q116, informational)")@@ -324,7 +324,7 @@ struct M4DuplicateScalePerformanceTests { let repository = try await store.openApp() let measured = try await measureDistributionAsync(iterations: 5) {- _ = try await repository.backupV8Snapshot()+ _ = try await repository.backupV9Snapshot() } reportPerformance("backup-projection-duplicate-free", measured) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftindex 72d3644..064dc4e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -11,16 +11,16 @@ import Testing /// the app. The defence is the readiness marker, read *before* either process /// constructs a container. ///-/// **The app opens two digits and the extension one.** `drop-superseded-columns`-/// publishes `"9"` and holds `"8"` in `appOpenableMarkerVersions` as the-/// generation V9 upgrades from (Req 2.2): the app opens it — the lightweight-/// stage drops the superseded columns inside `ModelContainer.init` — validates-/// the store and republishes at `"9"`, with no data pass and no reconciler-/// (Q9). The extension refuses `"8"` outright, because it holds only a shared-/// lock and must never convert or write, and for V9 that matters more than it-/// did for V8: the conversion it would otherwise run **removes** columns.-/// `"4"`–`"7"` stay retired (`data-model-cleanups` Decision 2, and Q2 of-/// `drop-superseded-columns` for `"7"`) and are refused by both.+/// **The app opens two digits and the extension one.**+/// `work-and-reading-status` publishes `"10"` and holds `"9"` in+/// `appOpenableMarkerVersions` as the generation V10 upgrades from (Req 9.1):+/// the app opens it — the lightweight stage adds the three defaulted status+/// columns inside `ModelContainer.init` — validates the store and republishes+/// at `"10"`, with no data pass and no reconciler. The extension refuses `"9"`+/// outright, because it holds only a shared lock and must never convert or+/// write. `"4"`–`"8"` stay retired (`data-model-cleanups` Decision 2, Q2 of+/// `drop-superseded-columns` for `"7"`, Q18 of `work-and-reading-status` for+/// `"8"`) and are refused by both. /// /// The extension's refusal therefore **forks** (Req 2.3), as it did before /// Decision 2 collapsed it: a generation the app opens is resolvable by opening@@ -51,7 +51,7 @@ struct MarkerContractTests { /// A first run: creates an empty store and marks it ready at birth. An /// empty store has nothing to migrate, so mark-at-birth certifies it at- /// `"9"` directly (Q26).+ /// `"10"` directly (Q26). private func makeReadyLibrary(_ configuration: LibraryConfiguration) async throws { _ = try await LibraryRepository.openForApp(configuration) }@@ -90,40 +90,40 @@ struct MarkerContractTests { // MARK: - App side accepts one generation - @Test("The app opens a library marked \"9\"")+ @Test("The app opens a library marked \"10\"") func appAcceptsTheCurrentMarkerVersion() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- #expect(try markerContent(cfg) == "9",+ #expect(try markerContent(cfg) == "10", "an empty store has nothing to bring forward, so it is certified at birth (Q26)") let (current, _) = try await LibraryRepository.openForApp(cfg) #expect(current == .ready(.seededEmpty), "a certified library opens in the app")- #expect(try markerContent(cfg) == "9", "and the open leaves the marker as it found it")+ #expect(try markerContent(cfg) == "10", "and the open leaves the marker as it found it") } - /// Req 2.2: the previous generation is *opened*, not refused — the stage- /// drops the superseded columns on the way in, and the app republishes at- /// the current generation once the store has validated (Q9).- @Test("The app opens a library marked \"8\" and republishes it at \"9\"")+ /// Req 9.1: the previous generation is *opened*, not refused — the stage+ /// adds the three defaulted columns on the way in, and the app republishes+ /// at the current generation once the store has validated.+ @Test("The app opens a library marked \"9\" and republishes it at \"10\"") func appUpgradesTheLaggingGeneration() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "8\n")+ try writeMarker(cfg, "9\n") let (result, repository) = try await LibraryRepository.openForApp(cfg) await repository.shutdown() #expect(result == .ready(.seededEmpty))- #expect(try markerContent(cfg) == "9",+ #expect(try markerContent(cfg) == "10", "the marker moves only after the converted store has validated") } /// The retired generations sit in this list beside the digits no build ever- /// published, which is the point of Decision 2: `"4"` through `"7"` are+ /// published, which is the point of Decision 2: `"4"` through `"8"` are /// now exactly as openable as `"45"`. @Test("The app fails closed on every marker version it does not open",- arguments: ["4\n", "5\n", "6\n", "7\n", "3\n", "45\n", "", "four\n"])+ arguments: ["4\n", "5\n", "6\n", "7\n", "8\n", "3\n", "45\n", "", "four\n"]) func appRejectsEveryOtherMarkerVersion(content: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -138,7 +138,8 @@ struct MarkerContractTests { /// The refusal names the digit, so the one library this can happen to says /// which generation it is on rather than only that it is wrong.- @Test("The refusal names the marker generation it found", arguments: ["4", "5", "6", "7"])+ @Test("The refusal names the marker generation it found",+ arguments: ["4", "5", "6", "7", "8"]) func appRefusalNamesTheRetiredGeneration(digit: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -155,11 +156,11 @@ struct MarkerContractTests { // MARK: - Extension side requires the current version - @Test("The extension opens a library marked \"9\"")+ @Test("The extension opens a library marked \"10\"") func extensionAcceptsTheCurrentVersion() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- #expect(try markerContent(cfg) == "9")+ #expect(try markerContent(cfg) == "10") let (result, _) = try await LibraryRepository.openForExtension(cfg) #expect(result == .ready(.seededEmpty))@@ -176,28 +177,27 @@ struct MarkerContractTests { } /// Req 8.7 of `configurable-work-types`, now with the live digit: between- /// the app being updated and first launched the library still records `"8"`,+ /// the app being updated and first launched the library still records `"9"`, /// and a capture in that window must fail safely rather than convert the- /// store under a shared lock — a V9 conversion **drops columns**, so the- /// hazard is destructive rather than merely additive. The message is the- /// actionable one, because opening the app is what resolves it (Req 2.3).+ /// store under a shared lock. The message is the actionable one, because+ /// opening the app is what resolves it (Req 9.3). @Test("The extension declines the update window and says to open the app") func extensionDeclinesTheUpdateWindow() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "8\n")+ try writeMarker(cfg, "9\n") await #expect(throws: Self.openTheApp) { try await LibraryRepository.openForExtension(cfg) }- #expect(try markerContent(cfg) == "8", "the extension may not republish readiness")+ #expect(try markerContent(cfg) == "9", "the extension may not republish readiness") } /// The other half of the fork: a generation the app does not open either /// keeps the "has not initialized" wording, because opening the app would /// not resolve it. @Test("The extension declines a retired generation with the unknown-digit message",- arguments: ["5", "6", "7"])+ arguments: ["5", "6", "7", "8"]) func extensionDeclinesARetiredGeneration(retired: String) async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)@@ -212,27 +212,26 @@ struct MarkerContractTests { @Test("The extension declines a \"4\" marker before it constructs a ModelContainer") func extensionDeclinesBeforeOpeningAContainer() async throws { let (_, cfg) = try config()- // A genuinely 8.0.0-recorded store, not a corrupt one: the container- // *would* open it, converting it to 9.0.0 in a process holding only a- // shared lock — and that conversion **drops columns**, so the hazard- // (Q14) is destructive now rather than merely additive. A store that- // cannot be opened at all would prove nothing about the ordering, which- // is why this uses the frozen-snapshot seed.- try V8RecordedStoreFixture.install(at: cfg.storeURL)+ // A genuinely 9.0.0-recorded store, not a corrupt one: the container+ // *would* open it, converting it to 10.0.0 in a process holding only a+ // shared lock (Q14). A store that cannot be opened at all would prove+ // nothing about the ordering, which is why this uses the+ // frozen-snapshot seed.+ try V9RecordedStoreFixture.install(at: cfg.storeURL) try writeMarker(cfg, "4\n") await #expect(throws: Self.declined) { try await LibraryRepository.openForExtension(cfg) }- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["8.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"], "the marker check must decide before ModelContainer.init converts anything") - // Control: with a "9" marker the same store is reached, opened, and+ // Control: with a "10" marker the same store is reached, opened, and // converted. Without this the assertion above could hold because the // store was unopenable rather than because the marker was read first.- try writeMarker(cfg, "9\n")+ try writeMarker(cfg, "10\n") _ = try await LibraryRepository.openForExtension(cfg)- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["9.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["10.0.0"], "the same store converts once the marker check passes") } @@ -244,7 +243,7 @@ struct MarkerContractTests { func extensionRejectsUnknownMarkerVersions() async throws { let (_, cfg) = try config() try await makeReadyLibrary(cfg)- try writeMarker(cfg, "10\n")+ try writeMarker(cfg, "99\n") await #expect(throws: Self.declined) { try await LibraryRepository.openForExtension(cfg)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTenTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTenTests.swiftnew file mode 100644index 0000000..c0ea3d5--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTenTests.swift@@ -0,0 +1,324 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The `"9"` → `"10"` generation, end to end (Req 9.1, 9.2, 9.3).+///+/// V10's arm has the same shape as V9's: the lightweight stage does the whole of+/// the conversion inside `ModelContainer.init`, so there is no data pass and no+/// reconciler to run after it. What is left is the sequence — open, validate,+/// publish — the failure that must leave the marker where it found it, and both+/// halves of the extension's fork.+///+/// What *is* new is the direction. Every stage before this one either added a+/// table or removed columns; V10 **adds three defaulted scalars to an existing+/// table**, and the conversion is the attribute defaults being written to every+/// existing row. `V9RecordedStoreTests` is where those defaults are asserted+/// column by column; this suite is about the marker and the order.+///+/// Every case runs over a library a **V9 build** left behind: a store recorded+/// at 9.0.0 with no status columns at all, marked `"9"`.+@Suite("Marker generation 10", .serialized)+struct MarkerGenerationTenTests {++ private final class Root {+ let url: URL+ let configuration: LibraryConfiguration+ init() throws {+ url = FileManager.default.temporaryDirectory.appending(+ path: "MarkerNine-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ configuration = LibraryConfiguration(rootDirectory: url)+ }+ deinit { try? FileManager.default.removeItem(at: url) }++ /// What a device that has run the V9 build holds: the store recorded at+ /// 9.0.0, and the marker at `"9"`.+ func seedV9Library() throws {+ try V9RecordedStoreFixture.install(at: configuration.storeURL)+ try writeMarker("9\n")+ }++ func writeMarker(_ content: String) throws {+ try Data(content.utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ }++ func markerText() throws -> String {+ try String(contentsOf: configuration.readinessMarkerURL, encoding: .utf8)+ .trimmingCharacters(in: .whitespacesAndNewlines)+ }+ }++ // MARK: - Req 9.2: the classification++ @Test("A \"9\" marker over a store classifies as the lagging generation")+ func nineIsLagging() throws {+ let root = try Root()+ try root.seedV9Library()++ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)+ == .markerLagging(generation: "9"))+ withExtendedLifetime(root) {}+ }++ @Test("A \"10\" marker over a store classifies ready")+ func tenIsReady() throws {+ let root = try Root()+ try root.seedV9Library()+ try root.writeMarker("10\n")++ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)+ withExtendedLifetime(root) {}+ }++ /// `"8"` joins the retired digits (Q18): every device is past it, so the arm+ /// that used to convert it is gone and the refusal names the digit like any+ /// other.+ @Test("Any other digit is unrecognised, and the refusal names it",+ arguments: ["4", "5", "6", "7", "8"])+ func otherDigitsAreUnrecognised(digit: String) throws {+ let root = try Root()+ try root.seedV9Library()+ try root.writeMarker("\(digit)\n")++ guard case .unrecognised(let reason) = try LibraryRepository.classify(+ root.configuration, fileManager: .default) else {+ Issue.record("expected \(digit) to be refused")+ return+ }+ #expect(reason.contains("\"\(digit)\""), "the refusal must name the digit: \(reason)")+ withExtendedLifetime(root) {}+ }++ // MARK: - Req 9.1: the arm++ /// The whole sequence: the store converts on the way in, it validates, and+ /// only then does the marker move. Nothing else runs — the stage adds+ /// defaulted columns and the launch reconcile handles anything that arrived+ /// since.+ @Test("The app arm converts, validates and republishes")+ func armRunsTheWholeSequence() async throws {+ let root = try Root()+ try root.seedV9Library()++ let (result, repository) = try await LibraryRepository.openForApp(root.configuration)+ defer { withExtendedLifetime(root) {} }++ guard case .ready(let counts) = result else {+ Issue.record("expected a ready library, got \(result)")+ await repository.shutdown()+ return+ }+ #expect(counts.works == 1)+ #expect(counts.entries == 3)+ #expect(try root.markerText() == "10")+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)+ == ["10.0.0"], "the store the arm opened is recorded at the version it converted to")++ let facts = try await repository.withLockedContext(+ mode: .shared, operation: "reading the converted library"+ ) { context in+ (+ memberships: try context.fetch(FetchDescriptor<WorkSiteMembership>()).map {+ "\($0.hostname)|\($0.urlIdentity ?? "-")|\($0.workID?.uuidString ?? "-")"+ },+ blobless: try context.fetch(FetchDescriptor<Entry>())+ .count(where: { $0.citationsData == nil })+ )+ }+ await repository.shutdown()+ #expect(facts.memberships == [+ "\(V9RecordedStoreFixture.hostname)|\(V9RecordedStoreFixture.workIdentity)"+ + "|\(V9RecordedStoreFixture.workID.uuidString)",+ ])+ // The arm converts nothing beyond the stage, so the fixture's one+ // nil-blob Entry is still nil-blob on the far side — a report, not a+ // quarantine (Q25 of `drop-superseded-columns`).+ #expect(facts.blobless == 1)+ }++ /// A second open of the same library takes the `.ready` arm and changes+ /// nothing: the generation moved once.+ @Test("The second open is an ordinary ready open")+ func secondOpenIsReady() async throws {+ let root = try Root()+ try root.seedV9Library()+ let (_, first) = try await LibraryRepository.openForApp(root.configuration)+ await first.shutdown()++ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)+ let (result, second) = try await LibraryRepository.openForApp(root.configuration)+ await second.shutdown()++ guard case .ready = result else {+ Issue.record("expected a ready library, got \(result)")+ return+ }+ #expect(try root.markerText() == "10")+ withExtendedLifetime(root) {}+ }++ /// Req 9.1: **the marker goes last**, so a throw anywhere above it leaves+ /// `"9"` on disk and the next open re-enters the arm over an already+ /// converted store — which is a no-op, because adding columns that are+ /// already there is one.+ ///+ /// The arm has exactly two things that can throw — the open and the+ /// validation — and both are above `publishReadiness`. This drives the open,+ /// because it is the one a test can force: the store is replaced by bytes no+ /// coordinator will read, restored, and opened again. What it pins is the+ /// ordering, not which of the two failed.+ @Test("A failed open leaves the marker at \"9\", and the next open completes it")+ func aFailedOpenLeavesTheMarkerAlone() async throws {+ let root = try Root()+ try root.seedV9Library()+ let intact = try Data(contentsOf: root.configuration.storeURL)+ try Data("not a database".utf8).write(to: root.configuration.storeURL, options: .atomic)++ await #expect(throws: (any Error).self) {+ try await LibraryRepository.openForApp(root.configuration)+ }+ #expect(try root.markerText() == "9",+ "the marker may not move over an open that did not complete")+ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)+ == .markerLagging(generation: "9"),+ "the next open re-enters the same arm")++ try intact.write(to: root.configuration.storeURL, options: .atomic)+ let (result, repository) = try await LibraryRepository.openForApp(root.configuration)+ await repository.shutdown()+ guard case .ready = result else {+ Issue.record("expected the retry to reach a ready library, got \(result)")+ return+ }+ #expect(try root.markerText() == "10")+ withExtendedLifetime(root) {}+ }++ /// Validation opens with diagnoses rather than refusing, exactly as the+ /// `.ready` arm does — a library that opened on V9 opens on V10, quarantines+ /// and all. Only a validation that *throws* stops the marker.+ @Test("A library with a quarantined hostname still opens, and reports it")+ func validationDiagnosesRatherThanRefuses() async throws {+ let root = try Root()+ try root.seedV9Library()+ // Break the cited chapter pattern's definition, which is an+ // `.unreadableTitlePattern` quarantine on V9 and must stay one on V10.+ do {+ let container = try LibraryRepository.openContainer(at: root.configuration.storeURL)+ let context = ModelContext(container)+ // The *active* pattern: since T-2289 a retired row that will not+ // decode is tolerated, so `.first` over an unordered fetch would+ // pin the quarantine only when it happened to land on this one.+ let pattern = try #require(+ try context.fetch(FetchDescriptor<TitlePattern>()).first(where: \.isActive))+ // The blob is where a definition lives since V9, so this is what a+ // rule the reader taught and something later broke looks like.+ pattern.definitionData = nil+ try context.save()+ withExtendedLifetime(container) {}+ }++ let (result, repository) = try await LibraryRepository.openForApp(root.configuration)+ let quarantined = await repository.quarantineReason(+ hostname: V9RecordedStoreFixture.hostname)+ await repository.shutdown()++ guard case .ready = result else {+ Issue.record("a diagnosable library must still open, got \(result)")+ return+ }+ #expect(try root.markerText() == "10")+ #expect(quarantined != nil, "the broken title rule quarantines its hostname, as on V9")+ withExtendedLifetime(root) {}+ }++ /// Q36's other half: **the cleanup goes after the publish**, so a marker+ /// write that fails leaves the historical marker and the migration sidecar+ /// where the next open expects to find them.+ ///+ /// The publish is forced to fail by taking write permission off the+ /// directory the readiness marker sits in. The **sidecar** is the+ /// load-bearing half of the assertion: it lives beside the store, in a+ /// directory that stays writable, so `clearResidualEvidence` would have+ /// removed it had the arm reached it.+ @Test("A failed publish leaves the historical marker and the sidecar in place")+ func aFailedPublishKeepsTheResidualEvidence() throws {+ let root = try Root()+ try root.seedV9Library()+ try Data("3\n".utf8).write(+ to: root.configuration.historicalMarkerURL, options: .atomic)+ try Data("stale\n".utf8).write(+ to: root.configuration.migrationSidecarURL, options: .atomic)++ let files = FileManager.default+ try files.setAttributes(+ [.posixPermissions: NSNumber(value: Int16(0o500))], ofItemAtPath: root.url.path)+ defer {+ try? files.setAttributes(+ [.posixPermissions: NSNumber(value: Int16(0o700))], ofItemAtPath: root.url.path)+ }++ #expect(throws: (any Error).self) {+ try LibraryRepository.act(+ on: .markerLagging(generation: "9"), root.configuration, hooks: .production)+ }+ #expect(try root.markerText() == "9",+ "the marker may not move over a publish that did not complete")+ #expect(files.fileExists(atPath: root.configuration.historicalMarkerURL.path))+ #expect(files.fileExists(atPath: root.configuration.migrationSidecarURL.path),+ "the sidecar is in a writable directory, so only the ordering keeps it")+ withExtendedLifetime(root) {}+ }++ // MARK: - Req 9.3: the extension's fork++ @Test("The extension refuses \"9\" and says to open the app")+ func extensionRefusesTheLaggingGeneration() async throws {+ let root = try Root()+ try root.seedV9Library()++ await #expect(throws: LibraryRepositoryError.libraryUnavailable(+ operation: "opening library from extension",+ reason: "Open Asterism to finish updating the library")) {+ try await LibraryRepository.openForExtension(root.configuration)+ }+ #expect(try root.markerText() == "9", "the extension may not convert or republish")+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)+ == ["9.0.0"], "and may not let ModelContainer.init convert the store")+ withExtendedLifetime(root) {}+ }++ @Test("The extension keeps the existing reason for a digit no build opens",+ arguments: ["4", "5", "6", "7", "8"])+ func extensionRefusesUnknownDigits(digit: String) async throws {+ let root = try Root()+ try root.seedV9Library()+ try root.writeMarker("\(digit)\n")++ await #expect(throws: LibraryRepositoryError.libraryUnavailable(+ operation: "opening library from extension",+ reason: "the containing app has not initialized the current library")) {+ try await LibraryRepository.openForExtension(root.configuration)+ }+ withExtendedLifetime(root) {}+ }++ @Test("The extension opens \"10\"")+ func extensionOpensTheCurrentGeneration() async throws {+ let root = try Root()+ try root.seedV9Library()+ let (_, repository) = try await LibraryRepository.openForApp(root.configuration)+ await repository.shutdown()+ #expect(try root.markerText() == "10")++ let (result, _) = try await LibraryRepository.openForExtension(root.configuration)+ guard case .ready = result else {+ Issue.record("expected the extension to open a certified library, got \(result)")+ return+ }+ withExtendedLifetime(root) {}+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swiftindex 68bc355..b9ae373 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift@@ -932,12 +932,12 @@ private final class ReconcileStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMembershipReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swiftindex 13cf9d3..79bf8c5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift@@ -109,7 +109,7 @@ extension LibraryRepository { } } - /// Exports and decode-validates, which is exactly what `BackupV8Exporter`+ /// Exports and decode-validates, which is exactly what `BackupV9Exporter` /// does — and `BackupArchiveReferenceChecks` is where a membership whose /// hostname and cited identity rule describe **different sites** is refused, /// along with the rest of the identity tuple. Three separate membership bugs@@ -119,11 +119,11 @@ extension LibraryRepository { sourceLocation: SourceLocation = #_sourceLocation ) async throws { do {- let payload = try await backupV8Snapshot()- let encoded = try BackupV8Codec.encode(+ let payload = try await backupV9Snapshot()+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))- _ = try BackupV8Codec.decode(encoded)+ metadata: BackupV9Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))+ _ = try BackupV9Codec.decode(encoded) } catch { Issue.record( comment ?? "the archive is not legal: \(error)", sourceLocation: sourceLocation)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swiftindex 72be2d0..15371d9 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift@@ -345,12 +345,12 @@ private final class MembershipStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMembershipValidation-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swiftindex 0b896a1..fc6273b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift@@ -123,9 +123,10 @@ struct MirroringBootstrapLifecycleTests { #expect(log.callCount == 1) let call = try #require(log.calls.first) // The whole point of the two-phase open: by the time the mirrored- // container is constructed, the store is marked "6" — so CloudKit cannot- // fill an unmarked store (Req 6.1, Q22, Q35).- #expect(call.markerVersion == "9")+ // container is constructed, the store is marked at the current+ // generation — so CloudKit cannot fill an unmarked store+ // (Req 6.1, Q22, Q35).+ #expect(call.markerVersion == "10") #expect(call.storeExists) #expect(call.containerID == Self.fixtureContainer) #expect(call.storeURL == configuration.storeURL)@@ -148,7 +149,7 @@ struct MirroringBootstrapLifecycleTests { mirroring: recordingHooks(configuration, log: log, bootstrapBox: bootstrapBox)) #expect(log.callCount == 1)- #expect(log.calls.first?.markerVersion == "9")+ #expect(log.calls.first?.markerVersion == "10") #expect(await repository.mirroring.isMirroring) // Q35/Q43 on this path too. The already-certified branch opens its own // certification container to run the validator over an existing marker,@@ -175,7 +176,7 @@ struct MirroringBootstrapLifecycleTests { #expect(result == .ready(LibraryRecordCounts( entries: 0, works: 0, sites: 1, titlePatterns: 0).withSeededWorkTypes))- #expect(log.calls.first?.markerVersion == "9")+ #expect(log.calls.first?.markerVersion == "10") #expect(await repository.mirroring.isMirroring) withExtendedLifetime(dir) {} }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex 921cc2f..89b4fdd 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -45,13 +45,19 @@ struct ModelContractTests { #expect(FieldOrder.allCases.map(\.rawValue) == ["chapterThenWork", "workThenChapter"]) #expect(CaptureTitleSource.allCases.map(\.rawValue) == ["host", "safariDocument", "networkFetch", "manual"]) #expect(Rating.allCases.map(\.rawValue) == ["up", "down"])- // The closed pre-feature type set. Since `configurable-work-types` these- // are no longer the app's type vocabulary — they are the raw values a- // pre-feature build can have left in `Work.typeRaw`, so a shipped- // spelling here can never change (Decision 4).- #expect(WorkType.allCases.map(\.rawValue) == ["novel", "toon", "article", "other"]) #expect(WorkTypeState.allCases.map(\.rawValue) == ["active", "removed", "merged"]) #expect(TitleProvenance.allCases.map(\.rawValue) == ["parsed", "manual"])+ // V10's two reader-entered statuses. The raw values are stored column+ // spellings and the case order is the fixed order Req 6.1 gives the+ // filter menus, so both are pinned here rather than derived at the two+ // call sites.+ #expect(WorkStatus.allCases.map(\.rawValue) == ["ongoing", "finished", "hiatus"])+ #expect(ReadingStatus.allCases.map(\.rawValue) == ["reading", "finished", "abandoned"])+ // The one derived predicate: everything that shows or hides the verdict+ // reads it.+ #expect(ReadingStatus.reading.isDone == false)+ #expect(ReadingStatus.finished.isDone)+ #expect(ReadingStatus.abandoned.isDone) #expect(FieldProvenanceKind.allCases.map(\.rawValue) == ["none", "pattern", "urlRule", "manual"]) #expect(SiteMode.allCases.map(\.rawValue) == ["untaught", "taught", "articles"]) #expect(AnchorOrigin.allCases.map(\.rawValue) == ["start", "end"])@@ -122,6 +128,17 @@ struct ModelContractTests { #expect(work.entryValues.isEmpty) #expect(work.genreTags.isEmpty) #expect(work.genericNotes.isEmpty)+ // V10's three columns (Reqs 1.1, 2.1, 2.6). Read through the accessors+ // **and** through the raw columns: the property initialiser is what+ // SwiftData turns into the Core Data attribute default, and that default+ // is what fills every existing row during the V9 → V10 stage, so a+ // reader that only asked the accessor would pass even if the default+ // never landed.+ #expect(work.workStatus == .ongoing)+ #expect(work.readingStatus == .reading)+ #expect(work.verdict.isEmpty)+ #expect(work.workStatusRaw == "ongoing")+ #expect(work.readingStatusRaw == "reading") #expect(entry.chapterTitle == nil) #expect(entry.chapterTitleProvenance == .none) #expect(entry.workAssignmentProvenance == .none)@@ -136,21 +153,42 @@ struct ModelContractTests { #expect(entry.intentionallyUnattached == false) } - /// The entity list is the store's shape. **V9 removes no table** — what it- /// removes is 35 attributes and one inverse pair — so the ten entities V8- /// declared are the ten V9 declares, in the same order. The frozen V8- /// snapshot is the `from` side of the one lightweight stage, so a divergence- /// here is a store that will not open, not a test that needs updating.- @Test("V9 declares the same ten entities V8 froze")+ /// The entity list is the store's shape. **V10 adds no table** — what it+ /// adds is three defaulted `Work` columns — so the ten entities V9 declared+ /// are the ten V10 declares, in the same order. The frozen V9 snapshot is+ /// the `from` side of the one lightweight stage, so a divergence here is a+ /// store that will not open, not a test that needs updating.+ @Test("V10 declares the same ten entities V9 froze") func schemaEntityLists() { let entities = [ "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity", "Character", "CharacterSuppression", "WorkSiteMembership", "WorkDistinctPair", ]+ #expect(AsterismSchemaV10.versionIdentifier == Schema.Version(10, 0, 0))+ #expect(AsterismSchemaV10.models.map { String(describing: $0) } == entities) #expect(AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0)) #expect(AsterismSchemaV9.models.map { String(describing: $0) } == entities)- #expect(AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0))- #expect(AsterismSchemaV8.models.map { String(describing: $0) } == entities)+ }++ /// The three columns V10 adds, as the *schema* records them rather than as+ /// the live classes declare them: defaulted, non-optional and non-unique,+ /// the CloudKit-mirrored shape every other column already has, and absent+ /// from the frozen V9 snapshot the stage converts from.+ @Test("The status columns are in V10 and not in the frozen V9")+ func statusColumnsAreV10Additions() {+ func workProperties(_ schema: Schema) -> Set<String> {+ guard let work = schema.entities.first(where: { $0.name == "Work" }) else { return [] }+ return Set(work.properties.map(\.name)).union(work.relationships.map(\.name))+ }+ let added = ["workStatusRaw", "readingStatusRaw", "verdict"]+ let live = workProperties(Schema(versionedSchema: AsterismSchemaV10.self))+ let frozen = workProperties(Schema(versionedSchema: AsterismSchemaV9.self))+ for column in added {+ #expect(live.contains(column), "Work.\(column) is missing from the V10 schema")+ #expect(!frozen.contains(column), "Work.\(column) is in the frozen V9 snapshot")+ }+ // The control: the frozen snapshot is a real schema, not an empty read.+ #expect(frozen.contains("titleProvenanceRaw")) } /// V7's additions, as CloudKit will materialise them: every property@@ -265,9 +303,9 @@ struct ModelContractTests { /// archive wire records, `trimPrefix` on `StoredPatternDefinition`) is not a /// column, and a textual grep could not tell them apart — which is why the /// V8-era half of this pin needed an allowlist and this one does not.- @Test("No dropped column is in the V9 schema")+ @Test("No dropped column is in the V10 schema") func droppedColumnsAreGoneFromTheSchema() {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) var propertiesByEntity: [String: Set<String>] = [:] for entity in schema.entities { propertiesByEntity[entity.name, default: []]@@ -278,7 +316,7 @@ struct ModelContractTests { for (entity, columns) in Self.droppedColumns { let held = propertiesByEntity[entity] ?? [] for column in columns {- #expect(!held.contains(column), "\(entity).\(column) is back in the V9 schema")+ #expect(!held.contains(column), "\(entity).\(column) is back in the V10 schema") } } // The control: the columns that superseded them *are* there, so a run@@ -288,15 +326,17 @@ struct ModelContractTests { #expect(propertiesByEntity["Work"]?.contains("siteMemberships") == true) } - /// **The frozen snapshot is exactly one file.** `AsterismSchemaV8.swift` is- /// the `from` side of the only stage the plan declares; V5, V6 and V7 went- /// with the stages that named them (Q2), and a file that starts declaring a- /// snapshot without a stage to be the `from` side of is a store shape- /// nothing can reach.+ /// **The frozen snapshot is exactly one file.** `AsterismSchemaV9.swift` is+ /// the `from` side of the only stage the plan declares; V5, V6, V7 and now+ /// V8 went with the stages that named them (Q2 of+ /// `drop-superseded-columns`, Q18 of `work-and-reading-status`), and a file+ /// that starts declaring a snapshot without a stage to be the `from` side of+ /// is a store shape nothing can reach. ///- /// The dropped columns are named in exactly that file and nowhere else in- /// the package's sources — comments excluded, because a comment saying which- /// column a blob superseded is the point of the comment.+ /// The columns V9 dropped are now named **nowhere** in the package's+ /// sources: the frozen V8 snapshot that was their last home went with the+ /// V8 → V9 stage. Comments are excluded from the reading, because a comment+ /// saying which column a blob superseded is the point of the comment. @Test("The frozen snapshot files are exactly the ones the plan names") func onlyTheFrozenSnapshotDeclaresTheDroppedColumns() throws { // The subset whose names no live type reuses. `siteHostname`,@@ -337,16 +377,17 @@ struct ModelContractTests { } #expect(- declaringSnapshots.sorted() == ["AsterismSchemaV8.swift", "AsterismSchemaV9.swift"],+ declaringSnapshots.sorted() == ["AsterismSchemaV10.swift", "AsterismSchemaV9.swift"], """ the package declares versioned schemas in \(declaringSnapshots.sorted()); \- the plan is [V8, V9] and every snapshot must be a stage's `from` side+ the plan is [V9, V10] and every snapshot must be a stage's `from` side """) #expect(- naming == ["AsterismSchemaV8.swift"],+ naming.isEmpty, """- these files name a dropped column: \(naming.sorted()). Only the \- frozen V8 snapshot may — read the blob or the membership instead.+ these files name a column the V8 → V9 stage dropped: \(naming.sorted()). \+ The frozen V8 snapshot that was allowed to is deleted (Q18) — read \+ the blob or the membership instead. """) } @@ -388,7 +429,7 @@ struct ModelContractTests { return found.sorted { $0.path < $1.path } } - /// V8's two additions, as CloudKit will materialise them: every property+ /// V8's two entities, as CloudKit will materialise them: every property /// defaulted or optional, nothing unique, both relationships nil-tolerant. @Test("WorkSiteMembership and WorkDistinctPair defaults are CloudKit-legal") func newEntityDefaults() {@@ -563,7 +604,7 @@ private struct ModelFixture { /// The live schema, in memory. It was `AsterismSchemaV2` — a schema no /// library was written by, which is exactly the divergence Req 4.1 is about. init() throws {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( schema: schema, isStoredInMemoryOnly: true,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swiftindex a28dce5..7ad7d04 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift@@ -121,10 +121,10 @@ struct MultiSiteReadPathTests { // The model itself: `siteMemberships` is declared here and // `membershipValues` / `membership(for:)` are its accessors. "Models.swift",- // The frozen V8 snapshot **declares** the inverse array; it reads+ // The frozen V9 snapshot **declares** the inverse array; it reads // nothing, and nothing reads it — a snapshot carries stored columns // and no accessors at all.- "AsterismSchemaV8.swift",+ "AsterismSchemaV9.swift", // The reconcilers and the scan, which are *about* the membership // graph and read it whole per pass rather than per row. "DuplicateReconciler.swift", "DuplicateScan.swift", "MembershipReconciler.swift",@@ -201,12 +201,12 @@ private final class MembershipStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismMultiSiteRead-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swiftindex 0692e24..1264a1f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift@@ -259,7 +259,7 @@ struct MultiSiteReviewFixTests { @Test("An Entry citing another site's rule is refused before a file exists") func exportGateRefusesACrossSiteEntryCitation() throws { let ruleID = UUID()- let rule = BackupV8URLRule(+ let rule = BackupV9URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("identity"))),@@ -268,12 +268,12 @@ struct MultiSiteReviewFixTests { hostname: "a.example", citations: EntryCitations(chapterSequence: CitedRule(id: ruleID))) - #expect(throws: BackupV8ExportError.self) {+ #expect(throws: BackupV9ExportError.self) { try LibraryRepository.requireCitationsResolve( entries: [entry], memberships: [], titlePatterns: [], urlRules: [rule]) } // Same rule, taught for the Entry's own site: legal.- let sameSite = BackupV8URLRule(+ let sameSite = BackupV9URLRule( id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch, origin: .readerTaught, definition: .work(locator: .query(name: ExactScalarString("identity"))),@@ -285,7 +285,7 @@ struct MultiSiteReviewFixTests { @Test("A chapter rule taught for another site is refused too") func exportGateRefusesACrossSitePatternCitation() throws { let patternID = UUID()- let pattern = BackupV8TitlePattern(+ let pattern = BackupV9TitlePattern( id: patternID, siteHostname: "b.example", version: 1, isActive: true, createdAt: Self.epoch, definition: StoredPatternDefinition(definition: .wholeTitle))@@ -295,7 +295,7 @@ struct MultiSiteReviewFixTests { chapterTitle: try FieldProvenance( kind: .pattern, patternID: patternID))) - #expect(throws: BackupV8ExportError.self) {+ #expect(throws: BackupV9ExportError.self) { try LibraryRepository.requireCitationsResolve( entries: [entry], memberships: [], titlePatterns: [pattern], urlRules: []) }@@ -303,9 +303,9 @@ struct MultiSiteReviewFixTests { private static func wireEntry( hostname: String, citations: EntryCitations- ) -> BackupV8Entry {+ ) -> BackupV9Entry { let url = "https://\(hostname)/one"- return BackupV8Entry(+ return BackupV9Entry( id: UUID(), captureTitle: "Chapter", captureTitleSource: .host, rawURL: url, canonicalURL: nil, hostname: hostname, entryIdentityKey: url, conservativeIdentityKey: url, identityBasis: .conservative, urlWorkIdentity: nil,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swiftindex 525cb61..a4a509c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift@@ -211,7 +211,8 @@ struct PostCollapseRedirectTests { titleProvenance: .parsed), draft: WorkMetadataDraft( displayTitle: "A Serial", typeAssignment: .none, genreTags: ["fantasy"],- genericNotes: "reader prose"))+ genericNotes: "reader prose",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) #expect(outcome == .committed) #expect(try library.workRows(id: survivor).map(\.genericNotes) == ["reader prose"])@@ -256,7 +257,8 @@ struct PostCollapseRedirectTests { titleProvenance: .parsed), draft: WorkMetadataDraft( displayTitle: "A Serial", typeAssignment: .none, genreTags: [],- genericNotes: "reader prose"))+ genericNotes: "reader prose",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) #expect(outcome == .committed) #expect(try library.workRows(id: survivor).map(\.genericNotes) == ["reader prose"])@@ -292,7 +294,8 @@ struct PostCollapseRedirectTests { lastParsedTitle: "A Serial", titleProvenance: .manual), draft: WorkMetadataDraft( displayTitle: "The Reader's Rename", typeAssignment: .none, genreTags: [],- genericNotes: ""))+ genericNotes: "",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) guard case .conflict(.survivorDiverged(_, let survivorID)) = outcome else { Issue.record("expected a diverged survivor, got \(outcome)")@@ -303,6 +306,87 @@ struct PostCollapseRedirectTests { == ["The Survivor's Own Title"]) } + /// Req 7.4: a status changed elsewhere between the read and the write is an+ /// edit conflict exactly as a differing manual title is. Without the three+ /// fields in `matches`, the redirect would fire and `updateWork` would write+ /// the draft's `reading` over the survivor's `abandoned` on every row.+ @Test("A survivor whose reading status differs refuses the redirected Work edit")+ func workRedirectRefusesADifferingStatus() async throws {+ let library = try WriteFixture()+ let survivor = UUID()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ let row = store.insertWork(+ id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+ // The reader abandoned it on the other device; everything else about+ // the row is still what the collapsed loser's basis says.+ row.readingStatus = .abandoned+ row.verdict = "not for me"+ }+ let repository = try await library.openForApp()++ let outcome = try await repository.updateWork(+ id: UUID(),+ basis: WorkEditBasis(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [], genericNotes: "",+ memberships: [WorkMembershipBasis(hostname: "dup.example", urlIdentity: nil)],+ lastParsedTitle: "A Serial", titleProvenance: .parsed),+ draft: WorkMetadataDraft(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [],+ genericNotes: "reader prose",+ workStatus: .ongoing, readingStatus: .reading, verdict: ""))++ guard case .conflict(.survivorDiverged(_, let survivorID)) = outcome else {+ Issue.record("expected a diverged survivor, got \(outcome)")+ return+ }+ #expect(survivorID == survivor)+ let rows = try library.workRows(id: survivor)+ #expect(rows.allSatisfy { $0.readingStatus == .abandoned })+ #expect(rows.allSatisfy { $0.verdict == "not for me" })+ }++ /// The other side of Req 7.4: a survivor whose statuses are exactly what the+ /// basis loaded still takes the redirected edit, marked work or not. Without+ /// this the refusal above would pass just as well against an implementation+ /// that refused *every* redirect touching a non-default status, which would+ /// lose the reader's edit rather than protect it.+ @Test("A survivor whose statuses match takes the redirected Work edit")+ func workRedirectAppliesToAMatchingStatus() async throws {+ let library = try WriteFixture()+ let survivor = UUID()+ try library.seed { store in+ store.insertSite(hostname: "dup.example")+ let row = store.insertWork(+ id: survivor, hostname: "dup.example", title: "A Serial", offset: 0)+ // Marked on both devices in the same way: the collapsed loser's+ // basis says exactly this.+ row.workStatus = .hiatus+ row.readingStatus = .abandoned+ row.verdict = "stalled"+ }+ let repository = try await library.openForApp()++ let outcome = try await repository.updateWork(+ id: UUID(),+ basis: WorkEditBasis(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [], genericNotes: "",+ memberships: [WorkMembershipBasis(hostname: "dup.example", urlIdentity: nil)],+ lastParsedTitle: "A Serial", titleProvenance: .parsed,+ workStatus: .hiatus, readingStatus: .abandoned, verdict: "stalled"),+ draft: WorkMetadataDraft(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [],+ genericNotes: "",+ workStatus: .finished, readingStatus: .finished,+ verdict: "a fine ending"))++ #expect(outcome == .committed)+ let rows = try library.workRows(id: survivor)+ #expect(rows.allSatisfy { $0.workStatus == .finished })+ #expect(rows.allSatisfy { $0.readingStatus == .finished })+ #expect(rows.allSatisfy { $0.verdict == "a fine ending" })+ }+ /// A bare survivor still takes the edit — the title match must not turn the /// ordinary redirect into a refusal. @Test("A bare survivor takes a redirected Work edit including the title")@@ -323,7 +407,8 @@ struct PostCollapseRedirectTests { titleProvenance: .parsed), draft: WorkMetadataDraft( displayTitle: "The Reader's Rename", typeAssignment: .none, genreTags: [],- genericNotes: ""))+ genericNotes: "",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) #expect(outcome == .committed) #expect(try library.workRows(id: survivor).map(\.displayTitle)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex 8022649..783e3ad 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -19,7 +19,7 @@ import Testing /// /// What that would actually re-enable is asserted here rather than assumed: /// capture would start applying an illegal Site's rules again-/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV8Exporter.swift:41` would+/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV9Exporter.swift:41` would /// stop gating. The four Req 3.4 write-path guards read /// `diagnostics.diagnoses` for `.duplicateSiteRows` (Q41), a class the scan does /// re-derive, so they are the weaker half of the assertion — pinned anyway,@@ -108,7 +108,7 @@ struct RefreshUnionInvariantTests { #expect(await repository.quarantineReason(hostname: tupleHost) != nil) for attempt in 0...2 { if attempt > 0 { try await repository.refreshDiagnostics() }- let payload = try await repository.backupV8Snapshot()+ let payload = try await repository.backupV9Snapshot() // One wire Site per hostname, including the duplicated one and the // rowless one (Q38, Q40). #expect(Set(payload.sites.map(\.hostname))
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swiftindex 915f459..8a26321 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift@@ -130,6 +130,57 @@ struct RepositoryCaptureTests { } #expect(try await fixture.repository.debugCounts() == .seededEmpty) }++ // MARK: - Req 2.6: the capture path never touches a status++ /// A capture that mints a Work hands the reader the defaults. The three+ /// columns are defaulted rather than `Work.create` parameters, so no+ /// creation door names them — which is what this reads back.+ @Test("A capture that mints a Work leaves it on the status defaults")+ func captureMintsAWorkOnTheDefaults() async throws {+ let fixture = try await CaptureRepositoryFixture()+ _ = try await fixture.repository.capture(+ .fixture(title: "A Serial", rawURL: "https://example.com/read?id=42&chapter=1"))+ try await fixture.teachWorkIdentity()++ _ = try await fixture.captureThroughRules(+ title: "A Serial", rawURL: "https://example.com/read?id=99&chapter=1")++ let work = try #require(+ try await fixture.repository.works().works+ .first { $0.memberships.contains { $0.urlIdentity == "99" } })+ #expect(work.workStatus == .ongoing)+ #expect(work.readingStatus == .reading)+ #expect(work.verdict == "")+ }++ /// Q8: captures never change either status. A reader who abandoned a work+ /// and then shares one more chapter of it has recorded a capture, not a+ /// change of heart.+ @Test("A capture into an abandoned Work leaves its statuses and verdict alone")+ func captureIntoAnAbandonedWorkChangesNothing() async throws {+ let fixture = try await CaptureRepositoryFixture()+ _ = try await fixture.repository.capture(+ .fixture(title: "A Serial", rawURL: "https://example.com/read?id=42&chapter=1"))+ try await fixture.teachWorkIdentity()+ let work = try #require(try await fixture.repository.works().works.first)+ try await fixture.repository.updateWork(+ id: work.id,+ draft: WorkMetadataDraft(+ displayTitle: work.displayTitle, typeAssignment: .none, genreTags: [],+ genericNotes: "", workStatus: .hiatus, readingStatus: .abandoned,+ verdict: "stopped at 12"))++ _ = try await fixture.captureThroughRules(+ title: "A Serial", rawURL: "https://example.com/read?id=42&chapter=2")++ let after = try #require(try await fixture.repository.works().works.first)+ #expect(after.id == work.id)+ #expect(after.entries.count == 2)+ #expect(after.workStatus == .hiatus)+ #expect(after.readingStatus == .abandoned)+ #expect(after.verdict == "stopped at 12")+ } } private extension CaptureDraft {@@ -189,6 +240,36 @@ private struct CaptureRepositoryFixture { repository = try await LibraryRepository.openForApp( configuration, clock: clock, saveStrategy: saveStrategy).repository }++ /// Teaches `example.com` a whole-title pattern and a URL rule that names the+ /// work by `id` and the chapter by `chapter`, which is what makes a capture+ /// mint or reuse a Work at all.+ func teachWorkIdentity() async throws {+ let request = ComposedTeachingRequest(+ titleDefinition: .wholeTitle,+ urlDefinition: .workAndSequence(+ work: URLFieldSelector(locator: .query(name: ExactScalarString("id"))),+ sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))))+ let contract = try await repository.projectComposedTeaching(+ hostname: "example.com", request: request)+ guard case .committed = try await repository.commitComposedTeaching(contract) else {+ throw CocoaError(.fileWriteUnknown)+ }+ }++ /// The taught capture path — `projectCapture` then `commitCapture` — which+ /// is the one that resolves a Work. `capture(_:)` is the conservative door+ /// and never touches one.+ @discardableResult+ func captureThroughRules(title: String, rawURL: String) async throws -> EntrySnapshot {+ let contract = try await repository.projectCapture(+ hostname: "example.com", captureTitle: title, captureTitleSource: .safariDocument,+ rawURLString: rawURL, canonicalURLString: nil, note: "", rating: nil)+ guard case .committed(let snapshot) = try await repository.commitCapture(contract) else {+ throw CocoaError(.fileWriteUnknown)+ }+ return snapshot+ } } private final class CaptureTemporaryDirectory {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swiftindex 73bb137..bbc585b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryTeachingTests.swift@@ -340,7 +340,13 @@ struct RepositoryTeachingTests { ) // Mutate a Work (changes basis)- try await fixture.repository.updateWork(id: w.id, draft: WorkMetadataDraft(displayTitle: "Fiction Renamed", typeAssignment: .configured(UUID(uuidString: "0E7A0000-0000-4000-8000-0000000000A1")!), genreTags: [], genericNotes: ""))+ try await fixture.repository.updateWork(+ id: w.id,+ draft: WorkMetadataDraft(+ displayTitle: "Fiction Renamed",+ typeAssignment: .configured(UUID(uuidString: "0E7A0000-0000-4000-8000-0000000000A1")!),+ genreTags: [], genericNotes: "",+ workStatus: .ongoing, readingStatus: .reading, verdict: "")) save.resetCount() let result = try await fixture.repository.commitTeaching(contract)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swiftindex bc424b3..0857271 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryWorksTests.swift@@ -91,7 +91,8 @@ struct RepositoryWorksTests { displayTitle: "Renamed", typeAssignment: .configured(Self.renamedTypeID), genreTags: [" fantasy ", "", "fantasy", "Fantasy", "action", "action "],- genericNotes: "Notes"+ genericNotes: "Notes",+ workStatus: .hiatus, readingStatus: .reading, verdict: "" ) ) let updated = try await fixture.repository.work(id: work.id)@@ -100,6 +101,9 @@ struct RepositoryWorksTests { #expect(updated.typeDisplay.assignment == .configured(Self.renamedTypeID)) #expect(updated.genreTags == ["fantasy", "Fantasy", "action"]) #expect(updated.genericNotes == "Notes")+ #expect(updated.workStatus == .hiatus)+ #expect(updated.readingStatus == .reading)+ #expect(updated.verdict == "") #expect(updated.titleProvenance == .manual) #expect(updated.modifiedAt == clock.now()) #expect(entryAfter.firstCapturedAt == entryBefore.firstCapturedAt)@@ -150,6 +154,56 @@ struct RepositoryWorksTests { #expect(assigned.workID == snapshot.works[0].id) } + /// Req 2.6: every creation door hands the reader a work on the defaults. The+ /// columns are defaulted rather than init parameters, so neither `create`+ /// door names them — which is exactly why they are asserted here.+ @Test("New Work and Move To a new work create a work on the status defaults")+ func creationDefaults() async throws {+ let fixture = try await WorksRepositoryFixture()+ let created = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "New Work", hostname: "example.com"))+ #expect(created.workStatus == .ongoing)+ #expect(created.readingStatus == .reading)+ #expect(created.verdict == "")++ let entry = try await fixture.repository.capture(.worksFixture())+ try await fixture.repository.moveEntry(+ entry.id, to: .newWork(displayTitle: "Created from Entry"))+ let moved = try #require(+ try await fixture.repository.works().works+ .first { $0.displayTitle == "Created from Entry" })+ #expect(moved.workStatus == .ongoing)+ #expect(moved.readingStatus == .reading)+ #expect(moved.verdict == "")+ }++ /// Req 7.1: a draft that moves nothing but a status is a real edit — it has+ /// to write and stamp, or the reader's only change would be dropped as a+ /// no-op.+ @Test("A status-only draft is a real edit")+ func statusOnlyEdit() async throws {+ let clock = WorksMutableClock(Date(timeIntervalSince1970: 100))+ let fixture = try await WorksRepositoryFixture(clock: clock)+ let work = try await fixture.repository.createWork(+ NewWorkDraft(displayTitle: "A Serial", hostname: "example.com"))+ clock.set(Date(timeIntervalSince1970: 500))++ try await fixture.repository.updateWork(+ id: work.id,+ draft: WorkMetadataDraft(+ displayTitle: "A Serial", typeAssignment: .none, genreTags: [],+ genericNotes: "",+ workStatus: .finished, readingStatus: .finished,+ verdict: "\n a fine ending\n"))++ let updated = try await fixture.repository.work(id: work.id)+ #expect(updated.workStatus == .finished)+ #expect(updated.readingStatus == .finished)+ // Q33: trimmed here, and nowhere else.+ #expect(updated.verdict == "a fine ending")+ #expect(updated.modifiedAt == clock.now())+ }+ @Test("Metadata and new-assignment failures leave prior state unchanged") func atomicFailures() async throws { let save = WorksControlledSaveStrategy()@@ -161,7 +215,10 @@ struct RepositoryWorksTests { await #expect(throws: LibraryRepositoryError.self) { try await fixture.repository.updateWork( id: work.id,- draft: WorkMetadataDraft(displayTitle: "Changed", typeAssignment: .configured(Self.renamedTypeID), genreTags: ["x"], genericNotes: "changed")+ draft: WorkMetadataDraft(+ displayTitle: "Changed", typeAssignment: .configured(Self.renamedTypeID),+ genreTags: ["x"], genericNotes: "changed",+ workStatus: .ongoing, readingStatus: .reading, verdict: "") ) } await #expect(throws: LibraryRepositoryError.self) {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swiftindex d0f1cb9..2dea359 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift@@ -98,7 +98,7 @@ struct RuleSelectionTests { /// hands back the same registered objects in the same order every time, /// which is a property of that context, not of the accessor. private func container() throws -> ModelContainer {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) return try ModelContainer( for: schema, configurations: [ModelConfiguration(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swiftindex 09b42d0..d1d298d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift@@ -453,12 +453,12 @@ private final class ReconcilerStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismSiteReconciler-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) self.saveStrategy = saveStrategy ?? saveRecorder
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swiftindex 7ed4248..b15492d 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -346,12 +346,12 @@ private final class ProjectionStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismSiteUnion-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swiftindex 38ca916..d711817 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift@@ -176,7 +176,7 @@ struct StoreMetadataTests { /// state is constructed here — the container stays live, holding its /// conversion in the log — and the reader has to see through it. ///- /// The store is seeded at 8.0.0 through the frozen snapshot. It used to be+ /// The store is seeded at 9.0.0 through the frozen snapshot. It used to be /// the 4.0.0 fixture, which made the stale reading directly visible /// (`.below("4.0.0")` versus `.atOrAboveV5`); each declared stage in turn /// refused an older store outright, so the only conversion left to hold in a@@ -187,17 +187,17 @@ struct StoreMetadataTests { @Test("A conversion still in the write-ahead log is read from the log, not the main file") func uncommittedConversionIsReadFromTheLog() throws { let dir = try TempDir()- try V8RecordedStoreFixture.install(at: dir.storeURL)+ try V9RecordedStoreFixture.install(at: dir.storeURL) #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5, "the premise: the seed is a store the classifier admits")- #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["8.0.0"])+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["9.0.0"]) let container = try LibraryRepository.openContainer(at: dir.storeURL) _ = ModelContext(container) try #require(FileManager.default.fileExists(atPath: dir.storeURL.path + "-wal"), "the conversion has to be in the log for this to be the hazard") - #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["9.0.0"],+ #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["10.0.0"], "the conversion is committed in the log, so a reader of the log sees it") #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5, "a reader that ignored the log would still have to answer, not refuse")
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftindex 3559ca1..9b8782a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -890,7 +890,7 @@ struct URLOptionalSequenceReconcilerTests { @Suite("Optional chapter sequence — archive") struct URLOptionalSequenceArchiveTests { - private static func combinedRule(of payload: BackupV8Payload) throws -> URLTwoFieldTemplate? {+ private static func combinedRule(of payload: BackupV9Payload) throws -> URLTwoFieldTemplate? { guard case .combined(_, let template) = try #require(payload.urlRules.first).definition else { return nil }@@ -903,12 +903,12 @@ struct URLOptionalSequenceArchiveTests { /// so this asserts the asymmetric `Codable` from the reading side. @Test("A pre-feature archive decodes, and its combined rule is still required") func preFeatureArchiveDecodes() throws {- let document = BackupV8Fixtures.sequencePresenceOmittedDocument()+ let document = BackupV9Fixtures.sequencePresenceOmittedDocument() #expect(!String(decoding: document, as: UTF8.self).contains("sequencePresence")) - let decoded = try BackupV8Codec.decode(document)+ let decoded = try BackupV9Codec.decode(document) - #expect(decoded.payload == BackupV8Fixtures.combinedRulePayload(presence: .required))+ #expect(decoded.payload == BackupV9Fixtures.combinedRulePayload(presence: .required)) #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .required) } @@ -917,17 +917,17 @@ struct URLOptionalSequenceArchiveTests { /// this feature would produce — which is what keeps it importable there. @Test("An archive with no declared-optional rule encodes the pre-feature bytes") func requiredArchiveIsByteIdenticalToPreFeature() throws {- let encoded = try BackupV8Codec.encode(- payload: BackupV8Fixtures.combinedRulePayload(presence: .required),- metadata: BackupV8Metadata(- appBuild: "pre-feature", exportedAt: BackupV8Fixtures.created))+ let encoded = try BackupV9Codec.encode(+ payload: BackupV9Fixtures.combinedRulePayload(presence: .required),+ metadata: BackupV9Metadata(+ appBuild: "pre-feature", exportedAt: BackupV9Fixtures.created)) let json = String(decoding: encoded, as: UTF8.self) #expect(!json.contains("sequencePresence")) #expect(- json.contains(BackupV8Fixtures.sequencePresenceOmittedPayloadJSON),+ json.contains(BackupV9Fixtures.sequencePresenceOmittedPayloadJSON), "the exported payload is no longer the pre-feature payload")- #expect(encoded == BackupV8Fixtures.sequencePresenceOmittedDocument())+ #expect(encoded == BackupV9Fixtures.sequencePresenceOmittedDocument()) } /// Req 5.4: a declared-optional rule survives export and import unchanged. The@@ -935,17 +935,17 @@ struct URLOptionalSequenceArchiveTests { /// `URLRulePattern` a reader would end up with, not merely a decoded value. @Test("A declared-optional rule round-trips through export and import") func optionalRuleRoundTripsThroughTheArchive() throws {- let payload = BackupV8Fixtures.combinedRulePayload(presence: .optional)- let encoded = try BackupV8Codec.encode(+ let payload = BackupV9Fixtures.combinedRulePayload(presence: .optional)+ let encoded = try BackupV9Codec.encode( payload: payload,- metadata: BackupV8Metadata(appBuild: "with-feature", exportedAt: BackupV8Fixtures.created))+ metadata: BackupV9Metadata(appBuild: "with-feature", exportedAt: BackupV9Fixtures.created)) #expect(String(decoding: encoded, as: UTF8.self).contains(#""sequencePresence":"optional""#)) - let decoded = try BackupV8Codec.decode(encoded)+ let decoded = try BackupV9Codec.decode(encoded) #expect(decoded.payload == payload) #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional) - let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let container = try ModelContainer( for: schema, configurations: [
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swiftindex d597044..bbfa49a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift@@ -7,17 +7,18 @@ import Testing /// The one store in the repository actually **recorded at 4.0.0**. ///-/// Nothing on this branch can write one any more: the live classes are V9's, so-/// every store a test creates today is recorded at 9.0.0 (or at 8.0.0, through-/// the frozen snapshot — see `V8RecordedStoreFixture`). It is the only input+/// Nothing on this branch can write one any more: the live classes are V10's, so+/// every store a test creates today is recorded at 10.0.0 (or at 9.0.0, through+/// the frozen snapshot — see `V9RecordedStoreFixture`). It is the only input /// that positively reads *below* V5, which is what `StoreMetadataTests`, /// `BootstrapClassifierTests` and `BootstrapActionTests` need it for. /// /// It is no longer a *convertible* store: since the plan declares real stages, a /// model version the plan does not declare is refused rather than raised-/// implicitly. Under `AsterismV9MigrationPlan` = `[V8, V9]` the floor has risen-/// three versions since that first became true, so 4.0.0 is refused with more-/// room to spare than ever. What can still be asserted about it is the refusal.+/// implicitly. Under `AsterismV10MigrationPlan` = `[V9, V10]` the floor has+/// risen four versions since that first became true, so 4.0.0 is refused with+/// more room to spare than ever. What can still be asserted about it is the+/// refusal. /// /// It was generated in a detached worktree at 8f5695a (the commit before the /// freeze, where the live classes *are* V4) by seeding one row of each of the@@ -55,8 +56,8 @@ enum V4RecordedStoreFixture { /// The schema versions Core Data recorded into the store's own metadata — /// `["4.0.0"]` for this fixture (nothing converts a 4.0.0 store any more;- /// `V8RecordedStoreTests` uses this helper to observe `"8.0.0"` before the- /// stage and `"9.0.0"` after it).+ /// `V9RecordedStoreTests` uses this helper to observe `"9.0.0"` before the+ /// stage and `"10.0.0"` after it). /// Read straight out of `Z_METADATA` rather than through SwiftData, so /// asking the question cannot itself perform the conversion. static func recordedModelVersions(at storeURL: URL) throws -> [String] {@@ -105,17 +106,17 @@ enum V4RecordedStoreFixture { /// stage ended that — a staged migration refuses a model version the plan does /// not declare — so the conversion those tests asserted, and the 5.0.0 end state /// they pinned, no longer exist to assert. The 432-Entry scale fixture went with-/// them: nothing can open it. `AsterismV9MigrationPlan` is `[V8, V9]`, so the-/// floor has since risen three more versions and 4.0.0 is refused by a wider+/// them: nothing can open it. `AsterismV10MigrationPlan` is `[V9, V10]`, so the+/// floor has since risen four more versions and 4.0.0 is refused by a wider /// margin than when this suite was written. /// /// Nothing shipped changes. `classify` already refuses a below-V5 store before /// any container is constructed (Req 2.9, Decision 1 of /// `retire-migration-chain`), and the recovery for one remains the 4/4 backup-/// archive. The conversion coverage is `V8RecordedStoreTests`, which seeds its-/// input through the frozen V8 snapshot — the version installed libraries-/// actually hold, and under `[V8, V9]` the only one that converts at all.-@Suite("A 4.0.0-recorded store under the V9 plan", .serialized)+/// archive. The conversion coverage is `V9RecordedStoreTests`, which seeds its+/// input through the frozen V9 snapshot — the version installed libraries+/// actually hold, and under `[V9, V10]` the only one that converts at all.+@Suite("A 4.0.0-recorded store under the V10 plan", .serialized) struct V4RecordedStoreTests { private final class TempDir {
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreFixture.swiftnew file mode 100644index 0000000..9b8b64b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreFixture.swift@@ -0,0 +1,480 @@+import Foundation+import SwiftData++@testable import AsterismCore++/// A store genuinely **recorded at 9.0.0**, seeded in-process through the frozen+/// `AsterismSchemaV9` snapshot — the library a device that ran the V9 build+/// holds on the morning of the V10 update.+///+/// It succeeds `V5`/`V6`/`V7`/`V8RecordedStoreFixture`, which went with the+/// stages that named them: the plan is `[V9, V10]`, so V9 is the only+/// convertible input left and anything older fails closed.+///+/// Seeding through the snapshot rather than committing a `.sqlite` is what the+/// nesting buys: a container over `AsterismSchemaV9` records 9.0.0 in the+/// store's own metadata, and the fixture cannot drift out of sync with the+/// snapshot it is built from.+///+/// **No `Work` row carries a status value**, because V9 has no column to put one+/// in. That is the whole point of this fixture at V10: what the stage has to+/// supply is `workStatusRaw = "ongoing"`, `readingStatusRaw = "reading"` and+/// `verdict = ""` on every existing row, out of the attribute defaults and with+/// no data pass behind them, and `V9RecordedStoreTests` asserts the **raw+/// columns** rather than the accessors — a `ToleratedEnum` read would answer+/// `.ongoing` even for a column the conversion never filled.+///+/// The rest of the inventory is `V8RecordedStoreFixture`'s, in V9's narrower+/// shape: one row per entity, **except `Entry`, which gets three, and+/// `TitlePattern`, which gets two**. Entry A carries the v2 identity arm and the+/// URL-rule work assignment, Entry B the v3 arm and the pattern assignment, and+/// **Entry C carries no citation blob at all** — the row a V9 build's share+/// extension wrote and nothing rewrote, which reads as the default and is+/// *reported* rather than quarantined (Q6, Q25 of `drop-superseded-columns`).+/// The second title rule is the retired segment arm, so the `definitionData`+/// blob is exercised on both arms. `Site.junkSuffixRule` is seeded non-nil+/// because it is the one composite Codable V9 still has.+///+/// V7's two tables ride through untouched, which is exactly why a `Character`+/// with a fact, a `CharacterSuppression` and a `WorkDistinctPair` are seeded: a+/// stage that lost a table nobody reads would otherwise be found on the owner's+/// phone.+///+/// ## Registry ordering: why seeding through a frozen snapshot is safe here+///+/// SwiftData keeps a **process-global, entity-name-keyed** registry, so a+/// container over this snapshot and a container over the live schema both claim+/// "Site", "Entry", … (`docs/agent-notes/schema-migration.md`; Q29 of+/// `drop-superseded-columns`, where a scratch container over the snapshot+/// aborted the whole test process with `NSUnknownKeyException` on `Site.works`).+///+/// What keeps this fixture out of that is **ordering, not luck**: `write(at:)`+/// creates the snapshot container, seeds through it, saves and releases it+/// before returning, and every caller opens the live container only afterwards.+/// The two are therefore never *in use* at the same time, which is the condition+/// that breaks — Q29's call site built its scratch container while a live one+/// was already open. `make test-core` passes `--no-parallel`, which is what+/// extends that guarantee across suites; dropping it would put another suite's+/// live container in use while this one holds the registration, and the package+/// would abort rather than fail a test.+///+/// **And this time the ordering is the only thing holding it up.** The V8+/// fixture could fall back on V9's stored shape being a strict *subset* of V8's+/// — the stage only removed — so a live key a stale V8 registration could not+/// answer did not exist. V10 **adds**: `workStatusRaw`, `readingStatusRaw` and+/// `verdict` are live keys this snapshot has never heard of, which is exactly+/// the direction that strands a save. The schema-migration note predicted this+/// at the previous bump; it is now lived rather than predicted, and the+/// create-seed-save-release ordering below is what answers it.+enum V9RecordedStoreFixture {+ static let hostname = "frozen9.example"+ static let siteDisplayName = "Frozen Nine"+ static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000009")!+ static let patternVersion = 5+ /// The Site's second, retired title rule — the segment arm. Inactive,+ /// because Decision 5 of `unified-teaching-composition` gives a taught Site+ /// exactly one active rule, and on a Site-unique version+ /// (`LibraryValidator`'s Site tuple).+ static let segmentPatternID = UUID(uuidString: "22222222-2222-2222-2222-000000000019")!+ static let segmentPatternVersion = 4+ static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000009")!+ static let urlRuleVersion = 3+ static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000009")!+ static let membershipID = UUID(uuidString: "99999999-9999-9999-9999-000000000009")!+ static let entryAID = UUID(uuidString: "55555555-5555-5555-5555-000000000009")!+ static let entryBID = UUID(uuidString: "55555555-5555-5555-5555-000000000019")!+ static let entryCID = UUID(uuidString: "55555555-5555-5555-5555-000000000029")!+ static let distinctPairID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-000000000009")!+ /// The other half of the seeded `WorkDistinctPair`. No `Work` carries it:+ /// the pair table names Works by application UUID and nothing prunes a row+ /// whose Work has gone, so a dangling id is a state the live library holds+ /// and the conversion must carry across unchanged.+ static let distinctPairOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-000000000019")!+ static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-000000000009")!+ static let workTypeName = "Web Serial"+ static let characterID = UUID(uuidString: "77777777-7777-7777-7777-000000000009")!+ static let characterName = "Nine of Frozen"+ static let characterNameKey = "nine of frozen"+ static let characterAliases = ["Nine", "Frozen Nine"]+ static let characterNote = "The one the fixture names."+ static let suppressionID = UUID(uuidString: "88888888-8888-8888-8888-000000000009")!+ static let suppressionNameKey = "the narrator"+ static let workName = "A Frozen Nine"+ static let genericNotes = "generic notes, recorded at 9.0.0"+ static let workURLString = "https://frozen9.example/series/99"+ static let workIdentity = "99"+ static let genreTags = ["frozen", "nine"]+ static let timestamp = Date(timeIntervalSince1970: 1_845_000_000)++ static let trimPrefix = "Read: "+ static let trimSuffix = " | Frozen Nine"+ static let phraseSeparator = " — "++ static let entryANote = "Recorded at 9.0.0 ✓"+ static let entryASequence = "11"+ static let entryAChapterTitle = "Chapter 11"+ static let entryACaptureTitle = "Read: Chapter 11 — A Frozen Nine | Frozen Nine"+ static let entryARawURL = "https://frozen9.example/read?series=99&chapter=11"++ static let entryACanonicalURL = "https://frozen9.example/read?chapter=11&series=99"++ static let entryBNote = "Recorded at 9.0.0, name-keyed"+ static let entryBSequence = "12"+ static let entryBChapterTitle = "Chapter 12"+ static let entryBCaptureTitle = "Read: Chapter 12 — A Frozen Nine | Frozen Nine"+ static let entryBRawURL = "https://frozen9.example/read?series=99&chapter=12"++ /// Entry C is the nil-blob row: captured through the share extension by a V8+ /// build and never rewritten, so its `citationsData` is still absent on the+ /// far side of the V8 → V9 drop. It reads as the default `EntryCitations`,+ /// which is why its identity fields are the conservative, unassigned tuple.+ static let entryCNote = "Captured before the citation blob existed"+ static let entryCCaptureTitle = "Read: Chapter 13 — A Frozen Nine | Frozen Nine"+ static let entryCRawURL = "https://frozen9.example/read?series=99&chapter=13"++ /// The fact the seeded character carries, cited from Entry A — so the+ /// `factsData` blob is genuinely populated rather than an empty array.+ static var characterFact: CharacterFact {+ CharacterFact(+ statement: "Nine is the narrator.", quote: "I am Nine.",+ nameKey: characterNameKey, source: .entry(entryAID))+ }++ /// The two coverage fingerprints, which are the *fingerprint of the text they+ /// cover*: a pass that covered this note would have written exactly this, so+ /// the seeded pair is self-consistent (Q81 of `character-extraction`).+ static var entryACoverage: String { CharacterCoverageFingerprint.of(entryANote) }+ static var workNotesCoverage: String { CharacterCoverageFingerprint.of(genericNotes) }++ /// The pattern arm the fixture seeds, as the live V10 type sees it.+ static var patternDefinition: PatternDefinition {+ .phrase(prefix: "", separator: phraseSeparator, suffix: "", order: .chapterThenWork)+ }++ /// The whole definition surface the `definitionData` blob holds — the arm+ /// *and* both trims. Since V9 the blob is the only home any of it has.+ static var storedPatternDefinition: StoredPatternDefinition {+ StoredPatternDefinition(+ definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix)+ }++ static var segmentWorkAnchor: SegmentRangeSpec {+ get throws { try SegmentRangeSpec(origin: .start, offset: 1, length: 2) }+ }++ static var segmentIgnoredAnchors: [SegmentPositionSpec] {+ get throws {+ [try SegmentPositionSpec(origin: .end, offset: 0),+ try SegmentPositionSpec(origin: .start, offset: 0)]+ }+ }++ /// The whole definition surface of the second, retired title rule.+ static var segmentStoredDefinition: StoredPatternDefinition {+ get throws {+ StoredPatternDefinition(+ definition: .segment(+ work: try segmentWorkAnchor, ignored: try segmentIgnoredAnchors),+ trimPrefix: nil, trimSuffix: nil)+ }+ }++ /// The Site's `junkSuffixRule` — the one composite Codable `Site` still+ /// carries, seeded non-nil so the suite can assert it field by field on the+ /// far side of the stage.+ static var siteJunkSuffixRule: JunkSuffixRule {+ get throws {+ try JunkSuffixRule(+ version: 3,+ anchors: [try SegmentPositionSpec(origin: .end, offset: 1),+ try SegmentPositionSpec(origin: .end, offset: 0)])+ }+ }++ /// The URL rule the fixture seeds, as the live V10 type sees it.+ static var urlRuleDefinition: URLRuleDefinition {+ .workAndSequence(+ work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),+ sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter"))))+ }++ private static var citedURLRule: CitedRule {+ CitedRule(id: urlRuleID)+ }++ private static var citedPattern: CitedRule {+ CitedRule(id: patternID)+ }++ /// Entry A's blob: the v2 identity arm and the URL-rule work assignment.+ static var entryACitations: EntryCitations {+ EntryCitations(+ identity: .rule(url: citedURLRule, nameTitle: nil),+ urlWork: citedURLRule,+ chapterSequence: citedURLRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: patternID),+ workAssignment: .urlRule(citedURLRule),+ workURL: citedURLRule,+ workURLAssignmentKind: .identity)+ }++ /// Entry B's blob: the v3 identity arm — the only one that carries a name+ /// contributor — and the pattern work assignment.+ static var entryBCitations: EntryCitations {+ EntryCitations(+ identity: .composed(url: citedURLRule, nameTitle: citedPattern),+ chapterSequence: citedURLRule,+ chapterTitle: FieldProvenance.tolerant(+ kind: .pattern, patternID: patternID),+ workAssignment: .pattern(citedPattern))+ }++ /// Opens a container over the frozen V9 snapshot at `storeURL`, hands its+ /// context to `seed`, saves, and releases the container so the file on disk+ /// is a closed store recorded at 9.0.0.+ ///+ /// **The order here is the safety property**, not the schema — see the type's+ /// doc comment. The snapshot container is released before this returns, so no+ /// caller can hold it open beside a live one.+ static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {+ try FileManager.default.createDirectory(+ at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+ let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let configuration = ModelConfiguration(+ // The same store-configuration name `openContainer` uses; a mismatch+ // here would make the reopen create a second store.+ "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)+ let container = try ModelContainer(for: schema, configurations: [configuration])+ let context = ModelContext(container)+ try seed(context)+ try context.save()+ withExtendedLifetime(container) {}+ }++ /// The Work name the seeded title rule derives from either capture title —+ /// computed rather than written out, so the v3 identity key the fixture+ /// stores always replays from the rule it cites.+ static func derivedWorkName(from captureTitle: String) throws -> String {+ guard case .success(let parsed) = TitleRuleApplicator.apply(+ definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,+ to: captureTitle) else {+ throw ModelInvariantError.invalidCombination(field: "V9 fixture title replay")+ }+ return parsed.workName+ }++ /// Entry A's stored `entryIdentityKey`, the v2 spelling.+ static var entryAIdentityKey: String {+ get throws {+ EntryIdentityKeyV2Codec.encode(+ try URLDerivedEntryIdentity(+ hostname: ExactScalarString(hostname),+ workIdentity: ExactScalarString(workIdentity),+ chapterSequence: ExactScalarString(entryASequence)))+ }+ }++ /// Entry B's, the v3 spelling.+ static var entryBIdentityKey: String {+ get throws {+ EntryIdentityKeyV3Codec.encode(+ try URLSequenceNameIdentity(+ hostname: ExactScalarString(hostname),+ workName: ExactScalarString(try derivedWorkName(from: entryBCaptureTitle)),+ chapterSequence: ExactScalarString(entryBSequence)))+ }+ }++ static func install(at storeURL: URL) throws {+ let v2Key = try entryAIdentityKey+ let v3Key = try entryBIdentityKey++ try write(at: storeURL) { context in+ let site = AsterismSchemaV9.Site()+ site.hostname = hostname+ site.displayName = siteDisplayName+ site.modeRaw = SiteMode.taught.rawValue+ site.junkSuffixRule = try siteJunkSuffixRule+ context.insert(site)++ // The phrase arm, in the blob that has been its only home since V9.+ let pattern = AsterismSchemaV9.TitlePattern()+ pattern.id = patternID+ pattern.version = patternVersion+ pattern.isActive = true+ pattern.createdAt = timestamp+ pattern.definitionData = try JSONEncoder().encode(storedPatternDefinition)+ context.insert(pattern)+ pattern.site = site++ // The retired segment arm. Inactive: a taught Site holds exactly one+ // active title rule.+ let segmentPattern = AsterismSchemaV9.TitlePattern()+ segmentPattern.id = segmentPatternID+ segmentPattern.version = segmentPatternVersion+ segmentPattern.isActive = false+ segmentPattern.createdAt = timestamp+ segmentPattern.definitionData = try JSONEncoder().encode(segmentStoredDefinition)+ context.insert(segmentPattern)+ segmentPattern.site = site++ let rule = AsterismSchemaV9.URLRulePattern()+ rule.id = urlRuleID+ rule.version = urlRuleVersion+ rule.isCurrent = true+ rule.createdAt = timestamp+ rule.originRaw = URLRuleOrigin.readerTaught.rawValue+ // Both fields from one rule, so an entry can cite it for its work+ // identity *and* its chapter sequence and still replay equal.+ rule.definitionData = try JSONEncoder().encode(urlRuleDefinition)+ context.insert(rule)+ rule.site = site++ let type = AsterismSchemaV9.WorkTypeEntity()+ type.id = workTypeID+ type.name = workTypeName+ type.stateRaw = WorkTypeState.active.rawValue+ type.createdAt = timestamp+ type.modifiedAt = timestamp+ type.nameModifiedAt = timestamp+ type.stateModifiedAt = timestamp+ context.insert(type)++ // **No status columns are set, because V9 has none.** Everything+ // here is a field V10 leaves exactly as it found it; the three the+ // stage supplies are asserted by their absence from this block.+ let work = AsterismSchemaV9.Work()+ work.id = workID+ work.displayTitle = workName+ work.lastParsedTitle = workName+ work.workTypeID = nil+ work.genreTags = genreTags+ work.genericNotes = genericNotes+ work.titleProvenanceRaw = TitleProvenance.manual.rawValue+ work.createdAt = timestamp+ work.modifiedAt = timestamp+ work.genericNotesExtractionFingerprint = workNotesCoverage+ context.insert(work)++ // The Work's site presence: since V9 the membership row is the only+ // home it has. It cites its identity rule by UUID alone (Req 10.4,+ // Q28 of `multi-site-works`), which is why no version travels with it.+ let membership = AsterismSchemaV9.WorkSiteMembership()+ membership.id = membershipID+ membership.hostname = hostname+ membership.createdAt = timestamp+ membership.urlIdentity = workIdentity+ membership.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue+ membership.urlIdentityRuleID = urlRuleID+ membership.workURLString = workURLString+ membership.workID = workID+ context.insert(membership)+ membership.work = work+ membership.site = site++ // Entry A: the v2 identity arm and URL-rule work assignment.+ let entryA = AsterismSchemaV9.Entry()+ entryA.id = entryAID+ entryA.captureTitle = entryACaptureTitle+ entryA.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryA.rawURLString = entryARawURL+ entryA.canonicalURLString = entryACanonicalURL+ entryA.entryIdentityKey = v2Key+ entryA.conservativeIdentityKey = entryARawURL+ entryA.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue+ entryA.hostname = hostname+ entryA.note = entryANote+ entryA.ratingRaw = Rating.up.rawValue+ entryA.firstCapturedAt = timestamp+ entryA.lastSharedAt = timestamp+ entryA.modifiedAt = timestamp+ entryA.urlWorkIdentity = workIdentity+ entryA.chapterSequence = entryASequence+ entryA.chapterTitle = entryAChapterTitle+ entryA.characterExtractionFingerprint = entryACoverage+ entryA.citationsData = try JSONEncoder().encode(entryACitations)+ context.insert(entryA)+ entryA.work = work+ entryA.site = site++ // Entry B: the v3 identity arm and the pattern work assignment.+ let entryB = AsterismSchemaV9.Entry()+ entryB.id = entryBID+ entryB.captureTitle = entryBCaptureTitle+ entryB.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryB.rawURLString = entryBRawURL+ entryB.entryIdentityKey = v3Key+ entryB.conservativeIdentityKey = entryBRawURL+ entryB.identityBasisRaw = EntryIdentityBasis.urlRule.rawValue+ entryB.hostname = hostname+ entryB.note = entryBNote+ entryB.firstCapturedAt = timestamp+ entryB.lastSharedAt = timestamp+ entryB.modifiedAt = timestamp+ entryB.chapterSequence = entryBSequence+ entryB.chapterTitle = entryBChapterTitle+ entryB.citationsData = try JSONEncoder().encode(entryBCitations)+ context.insert(entryB)+ entryB.work = work+ entryB.site = site++ // Entry C: **no citation blob**. It keeps its Work link — the+ // assignment provenance is what a V8 build never wrote, the+ // relationship is not (Q25 of `drop-superseded-columns`).+ let entryC = AsterismSchemaV9.Entry()+ entryC.id = entryCID+ entryC.captureTitle = entryCCaptureTitle+ entryC.captureTitleSourceRaw = CaptureTitleSource.host.rawValue+ entryC.rawURLString = entryCRawURL+ entryC.entryIdentityKey = entryCRawURL+ entryC.conservativeIdentityKey = entryCRawURL+ entryC.identityBasisRaw = EntryIdentityBasis.conservative.rawValue+ entryC.hostname = hostname+ entryC.note = entryCNote+ entryC.ratingRaw = Rating.down.rawValue+ entryC.firstCapturedAt = timestamp+ entryC.lastSharedAt = timestamp+ entryC.modifiedAt = timestamp+ entryC.citationsData = nil+ context.insert(entryC)+ entryC.work = work+ entryC.site = site++ // Nothing in the conversion reads this table, which is the reason to+ // assert it: a stage that lost one would surface as duplicate Works+ // the reader has already told the app are distinct.+ let pairIDs = WorkDistinctPair.sortedIDs(workID, distinctPairOtherWorkID)+ let pair = AsterismSchemaV9.WorkDistinctPair()+ pair.id = distinctPairID+ pair.lowerWorkID = pairIDs.lower+ pair.higherWorkID = pairIDs.higher+ pair.recordedAt = timestamp+ context.insert(pair)++ let character = AsterismSchemaV9.Character()+ character.id = characterID+ character.name = characterName+ character.nameKey = characterNameKey+ character.aliases = characterAliases+ character.note = characterNote+ character.factsData = CharacterFactCodec.encode([characterFact])+ character.createdAt = timestamp+ character.modifiedAt = timestamp+ context.insert(character)+ character.work = work++ let suppression = AsterismSchemaV9.CharacterSuppression()+ suppression.id = suppressionID+ suppression.kindRaw = CharacterSuppressionKind.candidate.rawValue+ suppression.nameKey = suppressionNameKey+ suppression.sourceKindRaw = nil+ suppression.sourceEntryID = nil+ suppression.evidence = nil+ suppression.statusRaw = CharacterSuppressionStatus.active.rawValue+ suppression.actionAt = timestamp+ context.insert(suppression)+ suppression.work = work+ }+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreTests.swiftnew file mode 100644index 0000000..31867d7--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V9RecordedStoreTests.swift@@ -0,0 +1,554 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The V9 → V10 conversion, over a store genuinely **recorded at 9.0.0**.+///+/// This is the path every installed library takes on the update that ships+/// `work-and-reading-status`: the store on disk was written by the V9 classes,+/// and `ModelContainer.init` runs the plan's one lightweight stage on the way+/// in. Every other store a test builds is born at 10.0.0, so a regression here+/// would otherwise only be visible on the owner's phone.+///+/// **This stage adds**, which is what makes the suite different from the+/// V5–V8 ones it replaces. Those pinned either that a superseded column survived+/// for a data pass to read, or that everything on the far side of a supersession+/// outlived a drop. There is no pass and nothing is dropped here: the three+/// columns are *defaulted*, so the whole of the conversion is SwiftData writing+/// each attribute's default into every existing row (Req 9.1).+///+/// The assertions are therefore in two halves. The first is that the defaults+/// landed, read through the **raw columns** — `work.workStatus` would answer+/// `.ongoing` through `ToleratedEnum` even for a column the conversion never+/// filled, so the accessor cannot tell a converted row from an unconverted one+/// and the raw string can. The second is that nothing else moved: the whole live+/// library, field by field, exactly as `V8RecordedStoreTests` asserted it.+@Suite("A 9.0.0-recorded store under the V10 plan", .serialized)+struct V9RecordedStoreTests {++ private typealias Fixture = V9RecordedStoreFixture++ /// A library exactly as a V9 build leaves it: the store recorded at 9.0.0+ /// with no status columns at all, and the marker at `"9"`.+ private final class Root {+ let url: URL+ let configuration: LibraryConfiguration++ init() throws {+ url = FileManager.default.temporaryDirectory.appending(+ path: "V9Recorded-\(UUID())", directoryHint: .isDirectory)+ try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+ configuration = LibraryConfiguration(rootDirectory: url)+ try Fixture.install(at: configuration.storeURL)+ try Data("9\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+ }++ deinit { try? FileManager.default.removeItem(at: url) }++ func markerText() throws -> String {+ try String(contentsOf: configuration.readinessMarkerURL, encoding: .utf8)+ .trimmingCharacters(in: .whitespacesAndNewlines)+ }++ func recordedVersions() throws -> [String] {+ try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)+ }+ }++ @Test("The seeded store really is recorded at 9.0.0, on the lagging marker")+ func seedIsRecordedAtNineZeroZero() throws {+ let root = try Root()+ #expect(try root.recordedVersions() == ["9.0.0"])+ #expect(try root.markerText() == "9")+ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)+ == .markerLagging(generation: "9"))+ withExtendedLifetime(root) {}+ }++ /// The whole of it: `openForApp` runs the stage, validates, publishes `"10"`,+ /// and every live row is still there afterwards with its value — plus the+ /// three the stage supplied.+ @Test("openForApp converts to 10.0.0, supplying the three defaults and losing nothing")+ func convertsWithEveryLiveRowIntact() async throws {+ let root = try Root()+ let (result, repository) = try await LibraryRepository.openForApp(root.configuration)+ defer { withExtendedLifetime(root) {} }++ guard case .ready(let counts) = result else {+ Issue.record("expected a ready library, got \(result)")+ await repository.shutdown()+ return+ }+ // The stage completed and the marker moved, in that order.+ #expect(try root.recordedVersions() == ["10.0.0"])+ #expect(try root.markerText() == "10")+ #expect(counts.works == 1)+ #expect(counts.entries == 3)+ #expect(counts.sites == 1)+ #expect(counts.titlePatterns == 2)+ #expect(counts.urlRulePatterns == 1)++ let facts = try await repository.withLockedContext(+ mode: .shared, operation: "reading the converted library"+ ) { context in try ConvertedV10Library(context: context) }+ await repository.shutdown()++ // MARK: what the stage added — the point of the whole suite+ //+ // **Raw columns, deliberately.** `workStatus` and `readingStatus` read+ // through `ToleratedEnum.read(_, default:)`, which answers the default+ // for an unrecognised spelling — an empty string included — so an+ // accessor assertion would pass over a column the conversion never+ // filled. The raw strings are what say the Core Data attribute defaults+ // actually landed on the row (Req 9.1).+ #expect(facts.workStatusRaw == "ongoing")+ #expect(facts.readingStatusRaw == "reading")+ #expect(facts.verdict.isEmpty)+ // And the accessors agree, which is what every reader will see.+ #expect(facts.workStatus == .ongoing)+ #expect(facts.readingStatus == .reading)++ // MARK: the Site+ #expect(facts.siteHostnames == [Fixture.hostname])+ #expect(facts.siteDisplayName == Fixture.siteDisplayName)+ #expect(facts.siteMode == .taught)++ let junk = try #require(facts.siteJunkSuffixRule)+ #expect(junk == (try Fixture.siteJunkSuffixRule))+ #expect(junk.version == 3)+ #expect(junk.anchors.map(\.origin) == [.end, .end])+ #expect(junk.anchors.map(\.offset) == [1, 0])++ // MARK: both title rules — the blob, which is their only home+ #expect(facts.patterns.count == 2)+ let phrasePattern = try #require(facts.patterns[Fixture.patternID])+ #expect(phrasePattern.storedDefinition == Fixture.storedPatternDefinition)+ #expect(phrasePattern.version == Fixture.patternVersion)+ #expect(phrasePattern.isActive)+ #expect(phrasePattern.siteHostname == Fixture.hostname)++ let segmentPattern = try #require(facts.patterns[Fixture.segmentPatternID])+ #expect(segmentPattern.storedDefinition == (try Fixture.segmentStoredDefinition))+ #expect(segmentPattern.storedDefinition.form == .segment)+ #expect(+ segmentPattern.storedDefinition.definition+ == .segment(+ work: try Fixture.segmentWorkAnchor,+ ignored: try Fixture.segmentIgnoredAnchors))+ #expect(segmentPattern.version == Fixture.segmentPatternVersion)+ #expect(!segmentPattern.isActive)+ #expect(segmentPattern.siteHostname == Fixture.hostname)++ // MARK: the URL rule+ #expect(facts.urlRuleID == Fixture.urlRuleID)+ #expect(facts.urlRuleVersion == Fixture.urlRuleVersion)+ #expect(facts.urlRuleIsCurrent)+ #expect(facts.urlRuleDefinition == Fixture.urlRuleDefinition)+ #expect(facts.urlRuleSiteHostname == Fixture.hostname)++ // MARK: the Work's other columns, none of which the stage touches+ #expect(facts.workID == Fixture.workID)+ #expect(facts.workDisplayTitle == Fixture.workName)+ #expect(facts.workLastParsedTitle == Fixture.workName)+ #expect(facts.workGenericNotes == Fixture.genericNotes)+ #expect(facts.workGenreTags == Fixture.genreTags)+ #expect(facts.workTitleProvenance == .manual)+ #expect(facts.workCreatedAt == Fixture.timestamp)+ #expect(facts.workNotesFingerprint == Fixture.workNotesCoverage)+ #expect(facts.workTypeID == nil)++ // MARK: the membership — the only home a Work's site presence has+ #expect(facts.memberships.count == 1)+ let membership = try #require(facts.memberships.first)+ #expect(membership.id == Fixture.membershipID)+ #expect(membership.hostname == Fixture.hostname)+ #expect(membership.createdAt == Fixture.timestamp)+ #expect(membership.urlIdentity == Fixture.workIdentity)+ #expect(membership.urlIdentityState == .rule)+ #expect(membership.urlIdentityRuleID == Fixture.urlRuleID)+ #expect(membership.workURLString == Fixture.workURLString)+ #expect(membership.hasWork)+ #expect(membership.siteHostname == Fixture.hostname)++ // MARK: the Entries — every reader-facing field, and the citation blob+ let entryA = try #require(facts.entries[Fixture.entryAID])+ #expect(entryA.captureTitle == Fixture.entryACaptureTitle)+ #expect(entryA.captureTitleSource == .host)+ #expect(entryA.rawURLString == Fixture.entryARawURL)+ #expect(entryA.canonicalURLString == Fixture.entryACanonicalURL)+ #expect(entryA.entryIdentityKey == (try Fixture.entryAIdentityKey))+ #expect(try EntryIdentityKeyV2Codec.decode(entryA.entryIdentityKey).chapterSequence+ == ExactScalarString(Fixture.entryASequence))+ #expect(entryA.conservativeIdentityKey == Fixture.entryARawURL)+ #expect(entryA.identityBasis == .urlRule)+ #expect(entryA.urlWorkIdentity == Fixture.workIdentity)+ #expect(entryA.chapterSequence == Fixture.entryASequence)+ #expect(entryA.chapterTitle == Fixture.entryAChapterTitle)+ #expect(entryA.note == Fixture.entryANote)+ #expect(entryA.rating == .up)+ #expect(entryA.firstCapturedAt == Fixture.timestamp)+ #expect(entryA.lastSharedAt == Fixture.timestamp)+ #expect(entryA.modifiedAt == Fixture.timestamp)+ #expect(!entryA.intentionallyUnattached)+ #expect(entryA.workID == Fixture.workID)+ #expect(entryA.siteHostname == Fixture.hostname)+ #expect(entryA.coverageFingerprint == Fixture.entryACoverage)+ #expect(entryA.citations == Fixture.entryACitations)++ let entryB = try #require(facts.entries[Fixture.entryBID])+ #expect(entryB.captureTitle == Fixture.entryBCaptureTitle)+ #expect(entryB.rawURLString == Fixture.entryBRawURL)+ #expect(entryB.canonicalURLString == nil)+ #expect(entryB.entryIdentityKey == (try Fixture.entryBIdentityKey))+ #expect(try EntryIdentityKeyV3Codec.decode(entryB.entryIdentityKey).workName+ == ExactScalarString(try Fixture.derivedWorkName(from: Fixture.entryBCaptureTitle)))+ #expect(entryB.chapterSequence == Fixture.entryBSequence)+ #expect(entryB.chapterTitle == Fixture.entryBChapterTitle)+ #expect(entryB.note == Fixture.entryBNote)+ #expect(entryB.rating == nil)+ #expect(entryB.lastSharedAt == Fixture.timestamp)+ #expect(entryB.modifiedAt == Fixture.timestamp)+ #expect(!entryB.intentionallyUnattached)+ #expect(entryB.workID == Fixture.workID)+ #expect(entryB.citations == Fixture.entryBCitations)++ // MARK: the nil-blob Entry, which crosses unchanged+ //+ // It has been a report rather than a quarantine since V9 (Q25 of+ // `drop-superseded-columns`), and an adding stage has no reason to touch+ // it — which is precisely why it is asserted: a conversion that+ // materialised a default blob here would be silently claiming the row+ // cites nothing rather than that nothing wrote it.+ let entryC = try #require(facts.entries[Fixture.entryCID])+ #expect(!entryC.hasCitationBlob)+ #expect(entryC.citations == EntryCitations())+ #expect(entryC.citations.identity == .rawURL)+ #expect(entryC.note == Fixture.entryCNote)+ #expect(entryC.rating == .down)+ #expect(entryC.rawURLString == Fixture.entryCRawURL)+ #expect(entryC.entryIdentityKey == Fixture.entryCRawURL)+ #expect(entryC.conservativeIdentityKey == Fixture.entryCRawURL)+ #expect(entryC.identityBasis == .conservative)+ #expect(entryC.workID == Fixture.workID)+ #expect(entryC.siteHostname == Fixture.hostname)+ #expect(entryC.lastSharedAt == Fixture.timestamp)+ #expect(entryC.modifiedAt == Fixture.timestamp)+ #expect(!entryC.intentionallyUnattached)++ #expect(facts.entriesWithoutCitationBlobCount == 1)++ // MARK: the distinct-pair row, which nothing in the stage reads+ #expect(facts.distinctPairs.count == 1)+ let pair = try #require(facts.distinctPairs.first)+ let pairIDs = WorkDistinctPair.sortedIDs(Fixture.workID, Fixture.distinctPairOtherWorkID)+ #expect(pair.id == Fixture.distinctPairID)+ #expect(pair.lowerWorkID == pairIDs.lower)+ #expect(pair.higherWorkID == pairIDs.higher)+ #expect(pair.recordedAt == Fixture.timestamp)++ // MARK: the work-type row, the Character and the suppression, none of+ // which the stage touches. `openForApp` seeds the default types on every+ // open (Q25 of `configurable-work-types`), so the fixture's row is the+ // one *extra* name.+ #expect(facts.workTypeNames.contains(Fixture.workTypeName))+ #expect(+ facts.workTypeNames.count == WorkTypeSeeding.seeds.count + 1,+ "the fixture's own type row survived beside the seeded defaults")+ #expect(facts.characterID == Fixture.characterID)+ #expect(facts.characterName == Fixture.characterName)+ #expect(facts.characterNameKey == Fixture.characterNameKey)+ #expect(facts.characterAliases == Fixture.characterAliases)+ #expect(facts.characterNote == Fixture.characterNote)+ #expect(facts.characterFacts == [Fixture.characterFact])+ #expect(facts.characterWorkID == Fixture.workID)+ #expect(facts.suppressionID == Fixture.suppressionID)+ #expect(facts.suppressionKind == .candidate)+ #expect(facts.suppressionNameKey == Fixture.suppressionNameKey)+ #expect(facts.suppressionStatus == .active)+ #expect(facts.suppressionActionAt == Fixture.timestamp)+ #expect(facts.suppressionWorkID == Fixture.workID)+ }++ /// The converted graph is one the validator accepts, with no hostname+ /// quarantined. Nothing about the three columns is validated — an unknown+ /// status raw is data, not damage — so what this pins is that the stage left+ /// a library that is still legal.+ @Test("The converted library validates with nothing quarantined")+ func convertedLibraryValidates() async throws {+ let root = try Root()+ let (_, repository) = try await LibraryRepository.openForApp(root.configuration)+ let quarantined = await repository.diagnostics.quarantineMap()+ let tuples = await repository.diagnostics.tupleDiagnoses+ await repository.shutdown()++ #expect(quarantined.isEmpty)+ #expect(tuples.isEmpty)+ withExtendedLifetime(root) {}+ }++ /// The extension is what the marker keeps out of the stage (Req 9.3): it+ /// refuses `"9"`, and opens the same library once the app has moved it.+ @Test("The extension refuses the store until the app has converted it")+ func extensionOpensOnlyAfterTheApp() async throws {+ let root = try Root()++ await #expect(throws: LibraryRepositoryError.libraryUnavailable(+ operation: "opening library from extension",+ reason: "Open Asterism to finish updating the library")) {+ try await LibraryRepository.openForExtension(root.configuration)+ }+ #expect(try root.recordedVersions() == ["9.0.0"],+ "the refusal has to land before ModelContainer.init converts the store")++ let (_, app) = try await LibraryRepository.openForApp(root.configuration)+ await app.shutdown()++ let (result, extensionRepository) = try await LibraryRepository.openForExtension(+ root.configuration)+ await extensionRepository.shutdown()+ guard case .ready(let counts) = result else {+ Issue.record("expected the extension to open a certified library, got \(result)")+ return+ }+ #expect(counts.entries == 3)+ withExtendedLifetime(root) {}+ }++ /// A second open takes the `.ready` arm: the generation moved once.+ @Test("The second open is an ordinary ready open")+ func secondOpenIsReady() async throws {+ let root = try Root()+ let (_, first) = try await LibraryRepository.openForApp(root.configuration)+ await first.shutdown()++ #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)+ let (_, second) = try await LibraryRepository.openForApp(root.configuration)+ await second.shutdown()+ #expect(try root.markerText() == "10")+ #expect(try root.recordedVersions() == ["10.0.0"])+ withExtendedLifetime(root) {}+ }+}++// MARK: - The converted library, as `Sendable` values++/// Everything the converted store holds, read once inside the locked context.+/// Model classes are not `Sendable` and may not leave the actor, so the whole+/// comparison is done against copies.+private struct ConvertedV10Library: Sendable {+ struct EntryFacts: Sendable {+ let captureTitle: String+ let captureTitleSource: CaptureTitleSource+ let rawURLString: String+ let canonicalURLString: String?+ let entryIdentityKey: String+ let conservativeIdentityKey: String+ let identityBasis: EntryIdentityBasis+ let urlWorkIdentity: String?+ let chapterSequence: String?+ let chapterTitle: String?+ let note: String+ let rating: Rating?+ let firstCapturedAt: Date+ let lastSharedAt: Date+ let modifiedAt: Date+ let intentionallyUnattached: Bool+ let workID: UUID?+ let siteHostname: String?+ let coverageFingerprint: String?+ let hasCitationBlob: Bool+ let citations: EntryCitations+ }++ struct PatternFacts: Sendable {+ let version: Int+ let isActive: Bool+ let siteHostname: String?+ let storedDefinition: StoredPatternDefinition+ }++ struct DistinctPairFacts: Sendable, Equatable {+ let id: UUID+ let lowerWorkID: UUID+ let higherWorkID: UUID+ let recordedAt: Date+ }++ struct MembershipFacts: Sendable {+ let id: UUID+ let hostname: String+ let createdAt: Date+ let urlIdentity: String?+ let urlIdentityState: WorkURLIdentityState+ let urlIdentityRuleID: UUID?+ let workURLString: String?+ let hasWork: Bool+ let siteHostname: String?+ }++ let siteHostnames: [String]+ let siteDisplayName: String+ let siteMode: SiteMode+ let siteJunkSuffixRule: JunkSuffixRule?++ let patterns: [UUID: PatternFacts]++ let urlRuleID: UUID+ let urlRuleVersion: Int+ let urlRuleIsCurrent: Bool+ let urlRuleSiteHostname: String?+ let urlRuleDefinition: URLRuleDefinition++ let workID: UUID+ let workDisplayTitle: String+ let workLastParsedTitle: String?+ let workGenericNotes: String+ let workGenreTags: [String]+ let workTitleProvenance: TitleProvenance+ let workCreatedAt: Date+ let workNotesFingerprint: String?+ let workTypeID: UUID?+ /// V10's three columns, **raw**. See the assertion block for why the raw+ /// strings rather than the accessors are what this suite compares.+ let workStatusRaw: String+ let readingStatusRaw: String+ let verdict: String+ let workStatus: WorkStatus+ let readingStatus: ReadingStatus++ let memberships: [MembershipFacts]+ let entries: [UUID: EntryFacts]+ let entriesWithoutCitationBlobCount: Int+ let distinctPairs: [DistinctPairFacts]+ let workTypeNames: [String]++ let characterID: UUID+ let characterName: String+ let characterNameKey: String+ let characterAliases: [String]+ let characterNote: String+ let characterFacts: [CharacterFact]+ let characterWorkID: UUID?++ let suppressionID: UUID+ let suppressionKind: CharacterSuppressionKind+ let suppressionNameKey: String+ let suppressionStatus: CharacterSuppressionStatus+ let suppressionActionAt: Date+ let suppressionWorkID: UUID?++ init(context: ModelContext) throws {+ let sites = try context.fetch(FetchDescriptor<Site>())+ let site = try #require(sites.first)+ siteHostnames = sites.map(\.hostname).sorted()+ siteDisplayName = site.displayName+ siteMode = site.mode+ siteJunkSuffixRule = site.junkSuffixRule++ patterns = Dictionary(+ uniqueKeysWithValues: try context.fetch(FetchDescriptor<TitlePattern>()).map {+ (+ $0.id,+ PatternFacts(+ version: $0.version, isActive: $0.isActive,+ siteHostname: $0.site?.hostname,+ storedDefinition: try $0.storedDefinition)+ )+ })++ let rule = try #require(try context.fetch(FetchDescriptor<URLRulePattern>()).first)+ urlRuleID = rule.id+ urlRuleVersion = rule.version+ urlRuleIsCurrent = rule.isCurrent+ urlRuleSiteHostname = rule.site?.hostname+ urlRuleDefinition = try rule.definition++ let work = try #require(try context.fetch(FetchDescriptor<Work>()).first)+ workID = work.id+ workDisplayTitle = work.displayTitle+ workLastParsedTitle = work.lastParsedTitle+ workGenericNotes = work.genericNotes+ workGenreTags = work.genreTags+ workTitleProvenance = work.titleProvenance+ workCreatedAt = work.createdAt+ workNotesFingerprint = work.genericNotesExtractionFingerprint+ workTypeID = work.workTypeID+ workStatusRaw = work.workStatusRaw+ readingStatusRaw = work.readingStatusRaw+ verdict = work.verdict+ workStatus = work.workStatus+ readingStatus = work.readingStatus++ memberships = try context.fetch(FetchDescriptor<WorkSiteMembership>())+ .sorted { $0.id.uuidString < $1.id.uuidString }+ .map {+ MembershipFacts(+ id: $0.id, hostname: $0.hostname, createdAt: $0.createdAt,+ urlIdentity: $0.urlIdentity, urlIdentityState: $0.urlIdentityState,+ urlIdentityRuleID: $0.urlIdentityRuleID, workURLString: $0.workURLString,+ hasWork: $0.work != nil, siteHostname: $0.site?.hostname)+ }++ let entryRows = try context.fetch(FetchDescriptor<Entry>())+ entriesWithoutCitationBlobCount =+ try LibraryToleranceScan.scan(context: context).entriesWithoutCitationBlobCount+ entries = try Dictionary(+ uniqueKeysWithValues: entryRows.map { entry in+ (+ entry.id,+ EntryFacts(+ captureTitle: entry.captureTitle,+ captureTitleSource: entry.captureTitleSource,+ rawURLString: entry.rawURLString,+ canonicalURLString: entry.canonicalURLString,+ entryIdentityKey: entry.entryIdentityKey,+ conservativeIdentityKey: entry.conservativeIdentityKey,+ identityBasis: entry.identityBasis,+ urlWorkIdentity: entry.urlWorkIdentity,+ chapterSequence: entry.chapterSequence,+ chapterTitle: entry.chapterTitle,+ note: entry.note,+ rating: entry.rating,+ firstCapturedAt: entry.firstCapturedAt,+ lastSharedAt: entry.lastSharedAt,+ modifiedAt: entry.modifiedAt,+ intentionallyUnattached: entry.intentionallyUnattached,+ workID: entry.work?.id,+ siteHostname: entry.site?.hostname,+ coverageFingerprint: entry.characterExtractionFingerprint,+ hasCitationBlob: entry.citationsData != nil,+ citations: try entry.citations)+ )+ })++ distinctPairs = try context.fetch(FetchDescriptor<WorkDistinctPair>())+ .sorted { $0.id.uuidString < $1.id.uuidString }+ .map {+ DistinctPairFacts(+ id: $0.id, lowerWorkID: $0.lowerWorkID, higherWorkID: $0.higherWorkID,+ recordedAt: $0.recordedAt)+ }++ workTypeNames = try context.fetch(FetchDescriptor<WorkTypeEntity>()).map(\.name).sorted()++ let character = try #require(try context.fetch(FetchDescriptor<CharacterRecord>()).first)+ characterID = character.id+ characterName = character.name+ characterNameKey = character.nameKey+ characterAliases = character.aliases+ characterNote = character.note+ characterFacts = character.facts+ characterWorkID = character.work?.id++ let suppression = try #require(+ try context.fetch(FetchDescriptor<CharacterSuppression>()).first)+ suppressionID = suppression.id+ suppressionKind = suppression.kind+ suppressionNameKey = suppression.nameKey+ suppressionStatus = suppression.status+ suppressionActionAt = suppression.actionAt+ suppressionWorkID = suppression.work?.id+ }+}
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swiftindex bed46b0..d8a7982 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift@@ -142,7 +142,90 @@ struct WorkMergePlannerTests { #expect(!outcome.discardedFields.contains(.sourceWorkURL)) } - @Test("Audit formatter escapes only the header and appends repeated blocks in order")+ /// V10 Req 7.3, Q13: the merged Work keeps the target's statuses and+ /// verdict, exactly as it keeps the target's type. What the source held is+ /// reported rather than applied — the two statuses as dropped fields, the+ /// verdict as reader text carried into the audit block (Q41).+ ///+ /// This source differs from the target by its **verdict alone** as far as+ /// the block's old gate was concerned: its title is parsed, it has no URL+ /// and no notes, so the block exists only because the verdict arm joined+ /// that gate.+ @Test("Merge keeps the target's statuses and verdict and audits a differing source's")+ func targetKeepsItsStatusesAndVerdict() throws {+ let rule = try queryRule()+ let target = work(+ id: 1, title: "Target", notes: "Target notes",+ workStatus: .finished, readingStatus: .finished, verdict: "a fine ending")+ let source = work(+ id: 2, title: "Source",+ workStatus: .hiatus, readingStatus: .abandoned, verdict: "gave up in book two")++ let outcome = try WorkMergePlanner.project(+ WorkMergeBasis(source: source, target: target, currentRule: rule))++ #expect(outcome.workStatus == .finished)+ #expect(outcome.readingStatus == .finished)+ #expect(outcome.verdict == "a fine ending")+ #expect(outcome.retainedFields.contains(.targetWorkStatus))+ #expect(outcome.retainedFields.contains(.targetReadingStatus))+ #expect(outcome.retainedFields.contains(.targetVerdict))+ #expect(outcome.discardedFields.contains(.sourceWorkStatus))+ #expect(outcome.discardedFields.contains(.sourceReadingStatus))+ #expect(outcome.discardedFields.contains(.sourceVerdict))+ // The preview's two captions come from this one flag: a status is+ // dropped, a verdict is written down.+ #expect(!WorkMergeField.sourceWorkStatus.recordedInNotes)+ #expect(!WorkMergeField.sourceReadingStatus.recordedInNotes)+ #expect(WorkMergeField.sourceVerdict.recordedInNotes)+ #expect(outcome.auditBlock == """+ --- Merged from: Source ---+ Verdict: gave up in book two+ """)+ #expect(outcome.genericNotes == "Target notes\n\n" + outcome.auditBlock!)+ }++ /// Q38: a default contributes nothing and a value identical to the target's+ /// is noise. Neither is a discarded field, and neither is worth a block.+ @Test("A default or identical source status and verdict produce no entry")+ func defaultAndIdenticalStatusesProduceNoEntry() throws {+ let rule = try queryRule()+ let target = work(+ id: 1, title: "Same",+ workStatus: .finished, readingStatus: .finished, verdict: "a fine ending")++ let onDefaults = try WorkMergePlanner.project(WorkMergeBasis(+ source: work(id: 2, title: "Same", verdict: "a fine ending"),+ target: target, currentRule: rule))+ #expect(onDefaults.discardedFields.isEmpty)+ #expect(onDefaults.auditBlock == nil)++ let agreeing = try WorkMergePlanner.project(WorkMergeBasis(+ source: work(+ id: 3, title: "Same", workStatus: .finished, readingStatus: .finished,+ verdict: "a fine ending"),+ target: target, currentRule: rule))+ #expect(agreeing.discardedFields.isEmpty)+ #expect(agreeing.auditBlock == nil)+ }++ /// A dropped status is *not* recorded anywhere: it is listed in the preview+ /// and that is the whole of it, so a source differing by status alone leaves+ /// the merged notes untouched.+ @Test("A differing source status alone is listed but writes no audit block")+ func aDifferingStatusWritesNoBlock() throws {+ let rule = try queryRule()+ let outcome = try WorkMergePlanner.project(WorkMergeBasis(+ source: work(id: 2, title: "Same", workStatus: .hiatus, readingStatus: .abandoned),+ target: work(id: 1, title: "Same", notes: "Target notes"),+ currentRule: rule))++ #expect(outcome.discardedFields == [.sourceWorkStatus, .sourceReadingStatus])+ #expect(outcome.auditBlock == nil)+ #expect(outcome.genericNotes == "Target notes")+ }++ @Test("Audit formatter escapes the structured lines and appends repeated blocks in order") func canonicalAuditGolden() { let first = WorkMergeAuditFormatter.block( sourceTitle: "A\\B\nC\rD",@@ -150,12 +233,27 @@ struct WorkMergePlannerTests { // site-specific address (Req 3.6) and a cross-site merge can discard // one on each of two sites. discardedWorkURLs: [(hostname: "example.com", url: "https://example.com/a")],+ // V10, Q41: the losing verdict is a structured line beside the URLs,+ // above the blank line that starts the free-form notes — so it is+ // escaped exactly as the header is. A verdict is multi-line reader+ // text (Req 2.3), and a raw newline in it would end the structured+ // region early.+ sourceVerdict: "gave up\\halfway\nin book two", sourceNotes: " notes \nkept\rverbatim " ) let expected = "--- Merged from: A\\\\B\\nC\\rD ---\n"- + "Work URL (example.com): https://example.com/a\n\n"+ + "Work URL (example.com): https://example.com/a\n"+ + "Verdict: gave up\\\\halfway\\nin book two\n\n" + " notes \nkept\rverbatim " #expect(first == expected)+ // The escape is what keeps the verdict on one line: the block's+ // structured region is the header plus the lines under it, and it ends+ // at the first blank line, which is the notes' own.+ #expect(first.components(separatedBy: "\n\n").first == """+ --- Merged from: A\\\\B\\nC\\rD ---+ Work URL (example.com): https://example.com/a+ Verdict: gave up\\\\halfway\\nin book two+ """) let second = WorkMergeAuditFormatter.block( sourceTitle: "Second", sourceNotes: ""@@ -235,6 +333,9 @@ struct WorkMergePlannerTests { url: String? = nil, notes: String = "", tags: [String] = [],+ workStatus: WorkStatus = .ongoing,+ readingStatus: ReadingStatus = .reading,+ verdict: String = "", identity: WorkIdentitySnapshot = .init(value: nil, state: .none, ruleReference: nil), entries: [EntrySnapshot] = [] ) -> WorkMergeWorkBasis {@@ -254,7 +355,10 @@ struct WorkMergePlannerTests { titleProvenance: titleProvenance, createdAt: Date(timeIntervalSince1970: 1), modifiedAt: Date(timeIntervalSince1970: 2),- entries: entries+ entries: entries,+ workStatus: workStatus,+ readingStatus: readingStatus,+ verdict: verdict ) return WorkMergeWorkBasis(snapshot: snapshot, identity: identity) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swiftindex 2183336..11b248e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift@@ -48,6 +48,49 @@ struct WorkMergeRepositoryTests { #expect(target.entries.count == 2) } + /// V10 Req 7.3 end to end: the committed Work carries the target's statuses+ /// and verdict, and the source's verdict survives as reader text in the+ /// merged notes while its statuses are only reported (Q13, Q41).+ @Test("Merge keeps the target's statuses and carries the source's verdict into the notes")+ func mergeKeepsTargetStatusesAndRecordsSourceVerdict() async throws {+ let fixture = try await MergeFixture()+ let (sourceID, targetID) = try await fixture.makeSourceAndTarget()+ try await fixture.repository.updateWork(+ id: targetID,+ draft: WorkMetadataDraft(+ displayTitle: "Target Work", typeAssignment: .none, genreTags: [],+ genericNotes: "target notes",+ workStatus: .finished, readingStatus: .finished, verdict: "a fine ending"))+ try await fixture.repository.updateWork(+ id: sourceID,+ draft: WorkMetadataDraft(+ displayTitle: "Source Work", typeAssignment: .none, genreTags: [],+ genericNotes: "",+ workStatus: .hiatus, readingStatus: .abandoned,+ verdict: "gave up in book two"))++ let contract = try await fixture.repository.projectMerge(+ sourceWorkID: sourceID, targetWorkID: targetID)++ #expect(contract.outcome.workStatus == .finished)+ #expect(contract.outcome.readingStatus == .finished)+ #expect(contract.outcome.verdict == "a fine ending")+ #expect(contract.outcome.discardedFields.contains(.sourceWorkStatus))+ #expect(contract.outcome.discardedFields.contains(.sourceReadingStatus))+ #expect(contract.outcome.discardedFields.contains(.sourceVerdict))++ guard case .committed = try await fixture.repository.commitMerge(contract) else {+ Issue.record("Expected .committed")+ return+ }++ let target = try await fixture.repository.work(id: targetID)+ #expect(target.workStatus == .finished)+ #expect(target.readingStatus == .finished)+ #expect(target.verdict == "a fine ending")+ #expect(target.genericNotes.contains("Verdict: gave up in book two"))+ }+ // MARK: - Refetch/rebuild/compare (stale refresh) @Test("Stale basis from intervening metadata edit returns .refreshed with zero saves")@@ -68,7 +111,8 @@ struct WorkMergeRepositoryTests { displayTitle: "Changed Target", typeAssignment: .configured(UUID(uuidString: "0E7A0000-0000-4000-8000-0000000000A1")!), genreTags: [],- genericNotes: "Changed"+ genericNotes: "Changed",+ workStatus: .ongoing, readingStatus: .reading, verdict: "" ) ) fixture.save.resetCounts()
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swiftindex 499fbb8..c5f8653 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkSnapshotMembershipTests.swift@@ -173,6 +173,42 @@ struct WorkSnapshotMembershipTests { #expect(basis.primaryHostname == "") } + // MARK: - Statuses and verdict++ /// The three V10 columns reach the surfaces through the same snapshot every+ /// other authored field does (Req 2.4, 7.4), and the edit basis carries them+ /// so a change made elsewhere is an edit conflict rather than a silent+ /// overwrite.+ @Test("A Work snapshot and its edit basis carry the row's statuses and verdict")+ func snapshotCarriesStatuses() async throws {+ let fixture = try await M5Fixture()+ try await fixture.repository.seedM5Rows(+ sites: [M5SeedSite(hostname: "first.example")],+ works: [+ M5SeedWork(+ id: Self.workID, displayTitle: "A Serial", hostname: "first.example")+ ])++ let fresh = try await fixture.repository.work(id: Self.workID)+ #expect(fresh.workStatus == .ongoing)+ #expect(fresh.readingStatus == .reading)+ #expect(fresh.verdict == "")++ try await fixture.repository.seedWorkStatuses(+ id: Self.workID, workStatus: .finished, readingStatus: .abandoned,+ verdict: "lost the thread")++ let work = try await fixture.repository.work(id: Self.workID)+ #expect(work.workStatus == .finished)+ #expect(work.readingStatus == .abandoned)+ #expect(work.verdict == "lost the thread")++ let basis = WorkEditBasis(work: work)+ #expect(basis.workStatus == .finished)+ #expect(basis.readingStatus == .abandoned)+ #expect(basis.verdict == "lost the thread")+ }+ // MARK: - Basis entry @Test("WorkBasisEntry lists every membership hostname in membership order")@@ -209,4 +245,25 @@ extension LibraryRepository { try context.save() } }++ /// The three V10 columns written straight onto the row — the shape a second+ /// device's sync leaves, and the one way to reach a non-default status+ /// without going through `updateWork`, which is what the snapshot test is+ /// there to read independently of.+ func seedWorkStatuses(+ id: UUID, workStatus: WorkStatus, readingStatus: ReadingStatus, verdict: String+ ) async throws {+ try await withLockedContext(+ mode: .exclusive, operation: "seeding Work statuses"+ ) { context in+ let works = try context.fetch(+ FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+ for work in works {+ work.workStatus = workStatus+ work.readingStatus = readingStatus+ work.verdict = verdict+ }+ try context.save()+ }+ } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swiftindex 17b6bb5..2455eba 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift@@ -31,9 +31,9 @@ struct WorkTypeConvergenceTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypeConvergence-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [ ModelConfiguration( "AsterismV3", schema: schema,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swiftindex 8fdf8a1..d571601 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift@@ -30,9 +30,9 @@ struct WorkTypeOrderingTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypeOrdering-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [ ModelConfiguration( "AsterismV3", schema: schema,
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swiftindex 78d76b6..4efffa7 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift@@ -30,12 +30,12 @@ struct WorkTypePlumbingTests { directory = FileManager.default.temporaryDirectory .appending(path: "WorkTypePlumbing-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swiftindex 59a6d33..67dd3c0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeResolutionSurfaceTests.swift@@ -134,11 +134,13 @@ struct WorkTypeResolutionSurfaceTests { let chosen = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil, genericNotes: "chosen", genreTags: [],- typeDisplay: WorkTypeDirectory.empty.display(of: .configured(Self.novel.id)))+ typeDisplay: WorkTypeDirectory.empty.display(of: .configured(Self.novel.id)),+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let other = WorkVariantSide( displayTitle: "T", titleProvenance: .parsed, hostname: "one.example", workURLString: nil, genericNotes: "other", genreTags: [],- typeDisplay: WorkTypeDirectory.empty.display(of: .configured(Self.webtoon.id)))+ typeDisplay: WorkTypeDirectory.empty.display(of: .configured(Self.webtoon.id)),+ workStatus: .ongoing, readingStatus: .reading, verdict: "") let union = WorkVariantUnion.fold(into: chosen, others: [other])
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swiftindex 4a4dba1..2fa6587 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeWritePathTests.swift@@ -198,7 +198,9 @@ struct WorkTypeWritePathTests { id: workID, basis: WorkEditBasis(work: work), draft: WorkMetadataDraft( displayTitle: work.displayTitle, typeAssignment: assignment,- genreTags: work.genreTags, genericNotes: work.genericNotes))+ genreTags: work.genreTags, genericNotes: work.genericNotes,+ workStatus: work.workStatus, readingStatus: work.readingStatus,+ verdict: work.verdict)) #expect(outcome == .committed) } }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex 1274e7b..dfbcbe5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -268,7 +268,7 @@ struct WriteSiteRelationshipTests { /// format change, no marker republication, no second pass. @Test("materializeArchive wires both relationships from its sitesByHostname map") func materializeWiresBothRelationships() throws {- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let container = try ModelContainer( for: schema, configurations: [ModelConfiguration(@@ -276,7 +276,7 @@ struct WriteSiteRelationshipTests { let context = ModelContext(container) try LibraryRepository.materializeArchive(- BackupImportPayload(BackupV8Fixtures.minimalTaughtPayload()), into: context)+ BackupImportPayload(BackupV9Fixtures.minimalTaughtPayload()), into: context) let site = try #require(try context.fetch(FetchDescriptor<Site>()).first) let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)@@ -286,18 +286,19 @@ struct WriteSiteRelationshipTests { withExtendedLifetime(container) {} } - // MARK: - B1: import into a library already marked "7"+ // MARK: - B1: import into a library already marked at the current generation /// The regression Decision 2 is written against. `confirmImport` reaches the /// live store's rows directly, and nothing republishes the readiness marker /// after it — so the relationship pass never runs again over what it wrote. /// If the importer did not set the relationships, nothing ever would.- @Test("Import into a \"7\"-marked library produces populated relationships")+ @Test("Import into a certified library produces populated relationships") func importPopulatesRelationships() async throws { let dir = try TempDir("WriteSiteImportFill") let cfg = configuration(dir) let (_, repository) = try await LibraryRepository.openForApp(cfg)- #expect(try markerContent(cfg) == "9", "mark-at-birth certifies an empty store at \"7\"")+ #expect(try markerContent(cfg) == "10",+ "mark-at-birth certifies an empty store at the current generation") let plan = try importPlan() let result = try await repository.confirmImport(plan: plan)@@ -305,7 +306,7 @@ struct WriteSiteRelationshipTests { Issue.record("expected committed, got \(result)") return }- #expect(try markerContent(cfg) == "9", "the import republishes nothing")+ #expect(try markerContent(cfg) == "10", "the import republishes nothing") try expectEveryRelationshipPopulated(cfg) withExtendedLifetime(dir) {} }@@ -323,7 +324,7 @@ struct WriteSiteRelationshipTests { Issue.record("expected committed, got \(result)") return }- #expect(try markerContent(cfg) == "9")+ #expect(try markerContent(cfg) == "10") try expectEveryRelationshipPopulated(cfg) withExtendedLifetime(dir) {} }@@ -410,7 +411,7 @@ struct WriteSiteRelationshipTests { } private func importPlan() throws -> BackupImportPlan {- let payload = BackupImportPayload(BackupV8Fixtures.minimalTaughtPayload())+ let payload = BackupImportPayload(BackupV9Fixtures.minimalTaughtPayload()) return BackupImportPlan( metadata: BackupImportMetadata( formatVersion: 8, schemaVersion: 9, appBuild: "test",@@ -420,7 +421,8 @@ struct WriteSiteRelationshipTests { counts: try LibraryRepository.validateImportPlanPayload(payload)) } - /// A nonempty store certified at `"7"` — the state an import replaces into.+ /// A nonempty store certified at the current generation (`"10"`) — the state+ /// an import replaces into. private func seedCertifiedLibrary(_ cfg: LibraryConfiguration, hostname: String) throws { try FileManager.default.createDirectory( at: cfg.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)@@ -436,7 +438,7 @@ struct WriteSiteRelationshipTests { entry.site = site context.insert(entry) try context.save()- try Data("8\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+ try Data("10\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic) withExtendedLifetime(container) {} }
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swiftindex 3b99c75..3d5a72f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift@@ -13,7 +13,7 @@ import Testing /// entity-list pin in `ModelContractTests`; both pass unchanged, so what is /// left to state here is the pair of version numbers and the one shape this /// feature actually introduces — a **heal-minted** membership, which must-/// serialise with the keys 8/9 already declared and no others.+/// serialise with the keys 9/10 already declared and no others. /// - **4.2**, the round trip. The heal can mint a membership for a hostname the /// library has no `Site` row for (Req 3.6), and that row is the one an archive /// has no obvious place for: the codec wants every hostname to name a Site.@@ -31,7 +31,7 @@ struct WrongHostWorkURLCompatibilityTests { static let movedURL = "https://elsewhere.example/work" static let epoch = Date(timeIntervalSince1970: 1_800_000_000) - /// The eight keys an 8/9 membership object has ever had. A minted row that+ /// The eight keys a 9/10 membership object has ever had. A minted row that /// needed a ninth would be a wire-format change, which is the thing /// [4.1](requirements.md#41) forbids. static let membershipKeys: Set<String> = [@@ -41,20 +41,16 @@ struct WrongHostWorkURLCompatibilityTests { // MARK: - Req 4.1 - @Test("The heal moves neither the store's schema version nor the archive's")- func versionsAreUnchanged() {- // The store: V9 is still the live schema and V8 the one frozen snapshot,- // so a library this feature has healed opens on every build that opened- // it before. No migration stage was added.- #expect(AsterismSchemaV9.versionIdentifier == Schema.Version(9, 0, 0))- #expect(AsterismSchemaV8.versionIdentifier == Schema.Version(8, 0, 0))- // The file: format 8 over schema 9, the pair every archive since- // `rule-citation-by-uuid` carries.- #expect(BackupV8Document.formatVersion == 8)- #expect(BackupV8Document.schemaVersion == 9)+ @Test("The heal moves the archive's version pair")+ func archiveVersionIsUnchanged() {+ // The file: format 9 over schema 10, the pair every archive since+ // `work-and-reading-status` carries. The heal did not move it; the+ // status columns did (Q17).+ #expect(BackupV9Document.formatVersion == 9)+ #expect(BackupV9Document.schemaVersion == 10) } - @Test("A healed library's archive uses only the membership keys 8/9 declared")+ @Test("A healed library's archive uses only the membership keys 9/10 declared") func healedArchiveAddsNoKey() async throws { let environment = try CompatibilityRoot() let archive = try await environment.healedArchive(workID: UUID())@@ -89,7 +85,7 @@ struct WrongHostWorkURLCompatibilityTests { // The archive: the minted row is there, and so is the untaught wire Site // the projection synthesised for its hostname — without which the // reference checks would refuse a membership naming no Site.- let decoded = try BackupV8Codec.decode(archive)+ let decoded = try BackupV9Codec.decode(archive) let minted = try #require( decoded.payload.memberships.first { $0.hostname == Self.destination }) #expect(minted.workID == workID)@@ -155,10 +151,10 @@ private struct CompatibilityRoot { let outcome = try await repository.reconcileAfterSync() #expect(outcome.memberships.movedWorkURLs == 1) - let exporter = BackupV8Exporter(+ let exporter = BackupV9Exporter( repository: repository, stagingDirectory: directory.appending(path: "staging")) let result = try await exporter.export(- metadata: BackupV8Metadata(+ metadata: BackupV9Metadata( appBuild: "test-1.0", exportedAt: WrongHostWorkURLCompatibilityTests.epoch)) let data = try Data(contentsOf: result.fileURL)
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swiftindex 5e08a40..4ce72e2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift@@ -311,7 +311,7 @@ struct WrongHostWorkURLImportTests { // A legacy identity state with no identity value: an illegal // membership tuple that has nothing to do with the Work URL. [- BackupV8Membership(+ BackupV9Membership( id: work, workID: work, hostname: siteHostname, createdAt: epoch, urlIdentity: nil, urlIdentityState: .legacyUnverified, urlIdentityRuleID: nil, workURLString: nil)@@ -401,14 +401,14 @@ private let epoch = Date(timeIntervalSince1970: 1_800_000_000) private func record( id: UUID = UUID(), work: UUID?, hostname: String, workURL: String?-) -> BackupV8Membership {- BackupV8Membership(+) -> BackupV9Membership {+ BackupV9Membership( id: id, workID: work, hostname: hostname, createdAt: epoch, urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: workURL) } -private func url(of records: [BackupV8Membership], _ id: UUID) -> String? {+private func url(of records: [BackupV9Membership], _ id: UUID) -> String? { records.first { $0.id == id }?.workURLString ?? nil } @@ -418,14 +418,14 @@ private func url(of records: [BackupV8Membership], _ id: UUID) -> String? { /// a test can address them. private func makePlan( activePattern: Bool = true,- memberships: (_ workID: UUID, _ otherMembershipID: UUID) -> [BackupV8Membership]+ memberships: (_ workID: UUID, _ otherMembershipID: UUID) -> [BackupV9Membership] ) -> BackupImportPlan { let workID = UUID() let otherMembershipID = UUID() let patternID = UUID() let rawURL = "https://\(siteHostname)/chapter/1" - let entry = BackupV8Entry(+ let entry = BackupV9Entry( id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host, rawURL: rawURL, canonicalURL: nil, hostname: siteHostname, entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,@@ -435,21 +435,22 @@ private func makePlan( intentionallyUnattached: false, citations: EntryCitations( workAssignment: .pattern(CitedRule(id: patternID))))- let work = BackupV8Work(+ let work = BackupV9Work( id: workID, displayTitle: "Imported Work", lastParsedTitle: "Imported Work",- genericNotes: "", genreTags: [], titleProvenance: .parsed, workTypeID: nil,+ genericNotes: "", genreTags: [], titleProvenance: .parsed,+ workStatus: .ongoing, readingStatus: .reading, verdict: "", workTypeID: nil, typeName: nil, createdAt: epoch, modifiedAt: epoch) // The rule is always in the archive so the Entry's citation resolves; what // `activePattern` varies is whether it is *active*, which is what makes the // taught Site's tuple legal or not. let patterns = [- BackupV8TitlePattern(+ BackupV9TitlePattern( id: patternID, siteHostname: siteHostname, version: 1, isActive: activePattern, createdAt: epoch, definition: StoredPatternDefinition(definition: .wholeTitle)) ] let sites = [siteHostname, otherHostname].map {- BackupV8Site(hostname: $0, displayName: $0, mode: $0 == siteHostname ? .taught : .untaught,+ BackupV9Site(hostname: $0, displayName: $0, mode: $0 == siteHostname ? .taught : .untaught, junkSuffixRule: nil) } let payload = BackupImportPayload(
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swiftindex 3351ab3..5c923fa 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift@@ -407,12 +407,12 @@ final class WrongHostStore { directory = FileManager.default.temporaryDirectory .appending(path: "AsterismWrongHostWorkURL-\(UUID())", directoryHint: .isDirectory) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)- let schema = Schema(versionedSchema: AsterismSchemaV9.self)+ let schema = Schema(versionedSchema: AsterismSchemaV10.self) let configuration = ModelConfiguration( "AsterismV3", schema: schema, url: directory.appending(path: "library.store"), cloudKitDatabase: .none) container = try ModelContainer(- for: schema, migrationPlan: AsterismV9MigrationPlan.self,+ for: schema, migrationPlan: AsterismV10MigrationPlan.self, configurations: [configuration]) context = ModelContext(container) }
diff --git a/docs/agent-notes/rule-wire-format.md b/docs/agent-notes/rule-wire-format.mdindex 8676495..afab968 100644--- a/docs/agent-notes/rule-wire-format.md+++ b/docs/agent-notes/rule-wire-format.md@@ -6,11 +6,15 @@ They reach persistence twice, by different routes: - **The store**: JSON in an opaque column — `URLRulePattern.definitionData`, and `TitlePattern`'s decoded columns. Mirrored to CloudKit as bytes, so no schema change is involved and no schema version protects it.-- **The archive**: the *typed* value inside `BackupV8URLRule.definition` /- `BackupV8TitlePattern.definition` — the live 8/9 wire substrate — re-encoded- and checksummed by `BackupV8Codec`. The record types are renamed with each+- **The archive**: the *typed* value inside `BackupV9URLRule.definition` /+ `BackupV9TitlePattern.definition` — the live 9/10 wire substrate — re-encoded+ and checksummed by `BackupV9Codec`. The record types are renamed with each generation, so a note naming `BackupV4*`/`BackupV6Codec` is describing a build- three generations back.+ several generations back. **The rename is not evidence that anything on this+ page changed**: 9/10 (`work-and-reading-status`) renamed the whole `BackupV8*`+ set outright because three reader-owned `Work` columns joined the archive, and+ neither rule definition moved. Read the generation as "which build wrote it",+ not as "which rule forms it can carry". So a change to one of these types has two independent compatibility stories, and the failure mode depends on *what kind* of change it is. This is not obvious from either call
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex c1c1adf..39584a7 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,6 +1,6 @@ # Schema migration -Schema **V9** is live (since `specs/drop-superseded-columns/`), with **V8**+Schema **V10** is live (since `specs/work-and-reading-status/`), with **V9** frozen beside it as the `from` side of one lightweight stage. The app opens `openForApp` and the share extension `openForExtension` — one opener per role, both over the same schema and the same file layout (`retire-migration-chain`'s@@ -10,32 +10,55 @@ 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 AsterismSchemaV9 { @Model final class Entry … }`+ classes live in `extension AsterismSchemaV10 { @Model final class Entry … }` (`Models.swift`) and are reached by top-level typealiases- (`typealias Entry = AsterismSchemaV9.Entry`). `AsterismSchemaV8.swift` holds+ (`typealias Entry = AsterismSchemaV10.Entry`). `AsterismSchemaV9.swift` holds the one frozen snapshot — stored columns and `@Relationship` macros only, `public init() {}`, no accessors. The nesting is what makes that snapshot legal; keep it.-- **`AsterismV9MigrationPlan` = `[V8, V9]`, one lightweight stage, and it only- *removes*.** V9 drops the 36 columns V8 kept unread — `Work`'s six- site/identity/URL columns and `typeRaw`, `Entry.identityKeyVersion` and its- seventeen citation/provenance columns, `TitlePattern`'s ten definition columns,- `Site.urlIdentityRule` — plus the `Site.works` ↔ `Work.site` inverse pair, which- go together because one is declared as the other's inverse. **This is the first- stage in the project's history that removes anything**, and two consequences- follow it around: the drop happens inside `ModelContainer.init`, so nothing can- read a column after the stage (Q4 of `drop-superseded-columns`), and a scratch- container over the *frozen* snapshot now aborts the process rather than- silently dropping a column — see the trap below.- `V8RecordedStoreTests` measures the stage over a store seeded through the- frozen V8 snapshot; `V4RecordedStoreTests` is the below-floor refusal suite.- `ModelContractTests` pins that none of the dropped names is in+- **`AsterismV10MigrationPlan` = `[V9, V10]`, one lightweight stage, and it only+ *adds*.** V10 is V9 plus three defaulted `Work` columns — `workStatusRaw`,+ `readingStatusRaw` and `verdict` (`work-and-reading-status`). No table is added+ or removed, no column changes type, no relationship changes shape, and the+ entity list is identical.+ **This is the first version in the project's history to add a non-optional+ scalar to an existing table under a bare lightweight stage.** The property+ initialiser is what SwiftData turns into the Core Data attribute default, and+ that default is what fills the three columns on every existing row inside+ `ModelContainer.init` — there is no data pass. `V9RecordedStoreTests`+ therefore asserts the **raw columns** after conversion, not the accessors: a+ `ToleratedEnum.read(_, default:)` accessor answers `.ongoing` whether or not+ the default ever landed, so asserting through it would prove nothing.+ `V4RecordedStoreTests` is the below-floor refusal suite.+ `ModelContractTests` pins both halves of the shape: the ten entities V9 froze+ are the ten V10 declares, and each of the three columns is present in+ `Schema(versionedSchema: AsterismSchemaV10.self)` and absent from the frozen+ V9 one. It still pins that none of V9's dropped names is in `Schema(...).entities`.- `AsterismSchemaV5/V6/V7` and their recorded-store suites are **deleted** (Q2):- every device is at marker `"8"`, so a stage below V8 is a path nothing can- reach.+ **The V8 → V9 stage is retired** (Q18): every device was confirmed at marker+ `"9"` on 2026-09-04 — `retire-migration-chain` Decision 6's population+ precondition — so `AsterismSchemaV8`, `AsterismV9MigrationPlan` and the+ `V8RecordedStore*` pair went with it, exactly as `AsterismSchemaV5/V6/V7` went+ at V9. A store below V9 fails closed with `NSCocoaErrorDomain` 134504 and the+ recovery is the backup archive.+ **What V9's removing stage cost is now history, and the new hazard is the+ opposite one.** V9 dropped 36 columns and the `Site.works` ↔ `Work.site`+ inverse pair, and its consequence was that a scratch container over the frozen+ snapshot *aborted the process* — see the trap below, which still stands. V10+ adds instead, and the consequence of adding is that **the live stored shape is+ no longer a subset of the frozen one**: see "Recorded-store fixtures and the+ registry".+- **The three new columns are read through tolerant accessors.**+ `Work.workStatus` / `readingStatus` go through+ `ToleratedEnum.read(_, default:)` on the `titleProvenance` pattern, so an+ unknown or empty raw spelling reads as `ongoing` / `reading` everywhere it is+ shown or filtered, while **export refuses the same value by name** — reading+ is tolerant, writing is not (`Models.swift`'s `ToleratedEnum` policy; Reqs+ 1.3, 2.7, 8.3). The raw spellings are frozen now that the V10 snapshot will+ one day exist; a committed edit writes what the picker shows, so an unknown+ raw does not survive an unrelated edit (Q19). - **No production data pass, and no `LegacyColumns`.** V8's columns were- mirrored, not stale, until this stage took them; `LegacyColumns`,+ mirrored, not stale, until the V9 stage took them; `LegacyColumns`, `V8PopulationPass` and `MembershipTestSupport`'s column doors are all gone. What replaced each mirror: - A Work's site presence is its `WorkSiteMembership`, read through@@ -66,13 +89,17 @@ background for the *next* schema bump and describes states that no longer exist. below the plan's floor before any container exists; the recovery is the backup archive), but **every test seeding a store older than the plan's floor and then opening it has to be rewritten at every bump.** The convertible fixture is now- `V8RecordedStoreFixture`, seeded in-process through the frozen V8 snapshot;+ `V9RecordedStoreFixture`, seeded in-process through the frozen V9 snapshot; `v4-recorded-4.0.0.sqlite` survives only as the one input that positively reads *below* the floor for the classifier suites.- **Expect the same at V10**: freezing V9 and declaring a V9 → V10 stage makes- every V8-seeded fixture unopenable in exactly this way, and the successor to- `V8RecordedStoreFixture` should be written by seeding through the *then*-frozen- V9 snapshot.+ **This is what happened at V10**, exactly as this note predicted it would:+ freezing V9 made every V8-seeded fixture unopenable, and+ `V8RecordedStoreFixture` had to be deleted in the *same task* that deleted the+ V8 snapshot rather than in the later task the plan listed, because the test+ target cannot compile with a fixture that opens a deleted schema (Q43).+ Expect the same at V11: seed the successor fixture through the *then*-frozen+ V10 snapshot, and delete its predecessor in the commit that deletes the schema+ it opened. - The P1 probe's other measurements still stand (`specs/retire-migration-chain/probe-result.md`, **PASS**): a store that had arrived at `5.0.0` by traversing the real chain reopens cleanly, both with@@ -107,38 +134,47 @@ background for the *next* schema bump and describes states that no longer exist. The V3 → V4 sidecar and completion pass (`V4Migration.buildSidecar` / `runCompletionPass`) are deleted; the sidecar *filename* survives because the classifier reads its presence to refuse an open over a vanished store (Q19).-- **The readiness marker holds `"9"`, and the app opens two digits.**- `extensionOpenableMarkerVersion` is `"9"` — the only one the extension opens+- **The readiness marker holds `"10"`, and the app opens two generations.**+ `extensionOpenableMarkerVersion` is `"10"` — the only one the extension opens and the only one `publishReadiness` writes — while- `appOpenableMarkerVersions` is `["8", "9"]`. An *empty* store is marked ready- at birth (Q26). A store carrying any other digit is refused, with the digit- named in the message, and the recovery is the backup archive.+ `appOpenableMarkerVersions` is `["9", "10"]` (`laggingOpenableMarkerVersion`+ is `"9"`). An *empty* store is marked ready at birth (Q26). A store carrying+ any other value is refused, with it named in the message, and the recovery is+ the backup archive. - **V9 substitutes rather than adds**: `"7"` is gone and `"8"` took its place.+ **`"10"` is the first generation spelled with two characters.** Nothing+ anywhere may assume a marker is one character long — not a parser, not a test+ fixture, not a comparison. `MarkerContractTests` and `MarkerGenerationTenTests`+ are where that is pinned.++ **V10 substitutes rather than adds**: `"8"` is gone and `"9"` took its place. The table below says that is only defensible after re-verifying the whole- population has passed the old digit, and Q2 of `drop-superseded-columns`- records that verification (one user, every device at `"8"` on 2026-08-27).- A `"9"` digit exists at all for a stage with no data pass because+ population has passed the old digit, and Q18 of `work-and-reading-status`+ records that verification (one user, every device at `"9"` on 2026-09-04),+ exactly as Q2 of `drop-superseded-columns` did a bump earlier.+ A `"10"` generation exists at all for a stage with no data pass because `openContainer` passes the migration plan for **both** roles, so the marker- check is the only thing keeping a *dropping* stage out of the share- extension (Q3).-- `BootstrapState.markerLagging` classifies an `"8"` store, and `act(on:)` runs:- open (which drops the columns) → `validateStore` → `publishReadiness` (writes- `"9"`) → `clearResidualEvidence`. **No data pass and no reconciler** (Q9):- V8's arm ran both because V8 *added* tables and blobs something had to fill,- and V9 only takes columns away inside `ModelContainer.init`. What certifies the- conversion is the store validating, so validation is the gate and the marker- goes after it — a throw leaves `"8"` on disk and the next open re-enters- the arm over an already-converted store, which is safe because dropping columns- that are already gone is a no-op.-- The extension's refusal **forks**: `"8"` gets "Open Asterism to finish updating- the library", anything else gets the shipped "has not initialized" wording.+ check is the only thing keeping the stage out of the share extension (Q3).++ `BootstrapState.markerLagging` classifies a `"9"` store, and `act(on:)` runs:+ open (which writes the three attribute defaults) → `validateStore` →+ `publishReadiness` (writes `"10"`) → `clearResidualEvidence`. **No data pass+ and no reconciler**, on the V9 arm's own grounds (Q9 of+ `drop-superseded-columns`): V8's arm ran both because V8 *added tables and+ blobs* something had to fill, whereas V9 only removed and V10 only adds+ defaulted scalars — in both cases inside `ModelContainer.init`. What certifies+ the conversion is the store validating, so validation is the gate and the+ marker goes after it — a throw leaves `"9"` on disk and the next open re-enters+ the arm over an already-converted store, which is safe because writing defaults+ that are already there is a no-op.++ The extension's refusal **forks**: `"9"` gets "Open Asterism to finish updating+ the library", anything else gets the shipped "has not initialized" wording+ (Req 9.3). The writer is `publishReadiness`, deliberately unversioned: it always writes- the current generation, and the digit has moved five times (4 → 5 → 6 → 7 →- 8 → 9), as the section below records. A nonempty+ the current generation, and the digit has moved six times (4 → 5 → 6 → 7 →+ 8 → 9 → 10), as the section below records. A nonempty unmarked store fails the open naming the state; an empty one is marked and opened — where "empty" means `LibraryRecordCounts.holdsNoReaderRecords`, which excludes the seeded work-type rows the app writes itself.@@ -156,30 +192,31 @@ background for the *next* schema bump and describes states that no longer exist. comment where it stood). "Work-only" is derived, not stored: `Site.isWorkOnlyTitleRule` is true when the active pattern is `.wholeTitle`. - **Capability gate is `.multiSite`** (`AsterismCapabilities.current`,- `multi-site-works` Q29). `BackupV8Codec` stamps the literal `"multi-site"`+ `multi-site-works` Q29). `BackupV9Codec` stamps the literal `"multi-site"` rather than reading `current`, so the archive's gate is independent of the- runtime's. 8/9 kept the literal (`rule-citation-by-uuid` Q19): the gate names a- store shape and a rule-form set, and neither changed. No `supports…` answer+ runtime's. 8/9 and 9/10 both kept the literal (`rule-citation-by-uuid` Q19):+ the gate names a store shape and a rule-form set, and neither changed. No `supports…` answer changed with the gate — every one is m4's — so the case exists to name the store shape it belongs to, and the *generation* is named by its format/schema numbers, which is what the importer gates on.-- **Backup writes and reads 8/9 only** (the `data-model-cleanups` Decision 2+- **Backup writes and reads 9/10 only** (the `data-model-cleanups` Decision 2 argument, made again: single-user population, fully migrated).- `BackupV8Exporter` is the only exporter and `BackupImporter.plan` accepts only- `supportedVersions` — `(BackupV8Document.formatVersion,- BackupV8Document.schemaVersion)`, i.e. `(8, 9)` — with any other pair refused+ `BackupV9Exporter` is the only exporter and `BackupImporter.plan` accepts only+ `supportedVersions` — `(BackupV9Document.formatVersion,+ BackupV9Document.schemaVersion)`, i.e. `(9, 10)` — with any other pair refused by version check, naming the detected pair, not by decode failure. The archive- format number is not the schema number: 8/9 is format 8 over schema 9, and+ format number is not the schema number: 9/10 is format 9 over schema 10, and since `rule-citation-by-uuid` Q9 the schema number names the *store* schema the archive was taken from. Every older import path — 2/2, 3/3, 4/4, 5/6, 6/7,- 7/8 — is **deleted**; recovering an older archive means checking out a build- that still carries its importer. **A 7/8 archive exported before this build is- unreadable by it** (Q8): the restorable archive is one exported *after*- upgrading. The historically-named V4/V5 *record types* (`BackupV4Entry`,- `BackupV5Work`, …) that used to be the payload's wire substrate are deleted- too: the payload is `BackupV8Payload` over `BackupV8Work`, `BackupV8Entry`,- `BackupV8Membership`, `BackupV8DistinctPair` and the rest, all named for the- format that carries them. `LegacyV2DateFormatter` and+ 7/8, 8/9 — is **deleted**; recovering an older archive means checking out a+ build that still carries its importer. **An 8/9 archive exported before this+ build is unreadable by it** (`work-and-reading-status` Q17): the restorable+ archive is one exported *after* upgrading. The historically-named V4/V5+ *record types* (`BackupV4Entry`, `BackupV5Work`, …) that used to be the+ payload's wire substrate are deleted too: the payload is `BackupV9Payload`+ over `BackupV9Work`, `BackupV9Entry`, `BackupV9Membership`,+ `BackupV9DistinctPair` and the rest, all named for the format that carries+ them. `LegacyV2DateFormatter` and `DuplicateJSONKeyValidator` live on in `BackupJSONCodecSupport.swift`; the live codec uses both. - **`AsterismSchemaV2` is gone, and so is the second file layout.** It was never@@ -197,24 +234,27 @@ background for the *next* schema bump and describes states that no longer exist. unopenable for no user value (Q7, Q13). `FrozenLibraryPathTests` fails if one moves. -## Adding a schema version (V9 and later)+## Adding a schema version (V10 and later) -V8 → V9 is the freshest worked example, and the only one that has ever-**removed** anything: `AsterismSchemaV8.swift` (the snapshot frozen by-`drop-superseded-columns`), `AsterismSchemaV9.swift` (live schema plus the plan),-the suite that measures the conversion (`V8RecordedStoreTests` over-`V8RecordedStoreFixture`) and the one that refuses anything older-(`V4RecordedStoreTests`). What a new version has to touch:+V9 → V10 is the freshest worked example, and the only one that has ever added a+**non-optional scalar to an existing table under a bare lightweight stage**:+`AsterismSchemaV9.swift` (the snapshot frozen by `work-and-reading-status`),+`AsterismSchemaV10.swift` (live schema plus the plan), the suite that measures+the conversion (`V9RecordedStoreTests` over `V9RecordedStoreFixture`) and the one+that refuses anything older (`V4RecordedStoreTests`). V8 → V9+(`drop-superseded-columns`) remains the only stage that has ever *removed*+anything. What a new version has to touch: | Step | Where | |---|---|-| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV9` proper — stored columns and `@Relationship` macros only, `public init() {}`, no accessors — and add `AsterismSchemaV10` with the new models; every entity nested, zero top-level `@Model` |-| Add the stage | `AsterismV9MigrationPlan`'s successor: `.lightweight(fromVersion: V9, toVersion: V10)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |-| Extend the accepted markers | `appOpenableMarkerVersions` and `extensionOpenableMarkerVersion` (`"9"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit — `drop-superseded-columns` Q2 is what that verification looks like written down |+| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV10` proper — stored columns and `@Relationship` macros only, `public init() {}`, no accessors — and add `AsterismSchemaV11` with the new models; every entity nested, zero top-level `@Model` |+| Add the stage | `AsterismV10MigrationPlan`'s successor: `.lightweight(fromVersion: V10, toVersion: V11)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |+| Give an added column a literal default | A defaulted, non-optional, non-unique scalar is the CloudKit-mirrored shape every column here already has, and its **property initialiser is what becomes the Core Data attribute default** — which is what fills existing rows during the stage. No default means no lightweight stage. Assert the **raw column** after conversion, not an accessor that would answer the default either way (`V9RecordedStoreTests`) |+| Extend the accepted markers | `appOpenableMarkerVersions`, `laggingOpenableMarkerVersion` and `extensionOpenableMarkerVersion` (`"10"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit — `work-and-reading-status` Q18 and `drop-superseded-columns` Q2 are what that verification looks like written down. The generation is a *string*, not a digit: `"10"` already has two characters. **Check what the suites use as their canonical *unrecognised* marker before taking the next value**: five suites used `"10"` for that, and it had to move to `"99"` when `"10"` went live, or they would have been asserting the refusal of a marker the app opens (Q28) | | Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness; a new marker generation needs a case there and an action beside it, not a guard inside the open |-| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`drop-superseded-columns` is the live worked example**: `BootstrapState.markerLagging` plus the `"8"` arm in `act(on:)`, with `MarkerGenerationNineTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the digit, and validation is what certifies it |-| Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently. This matters most for a stage that **removes** — a concurrent share-sheet open mid-conversion is destructive rather than merely early |-| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — `rule-citation-by-uuid` is the freshest worked example, 7/8 → 8/9 with `BackupV8Exporter`/`BackupV8Codec` replacing the V7 set outright (Q14), and `multi-site-works` did the same at 6/7 → 7/8 — or a backup silently stops round-tripping it. A stage that only *removes* store columns changes no wire shape: V9 re-recorded no golden. But a change to a record the archive *carries* does, whatever the store schema does — 8/9 is a codec change with no schema stage behind it, which is why Q9 pins the schema number to the store the archive was taken from. Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1` mode (Q22) rather than by hand |+| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`work-and-reading-status` is the live worked example**: `BootstrapState.markerLagging` plus the `"9"` arm in `act(on:)`, with `MarkerGenerationTenTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the generation, and validation is what certifies it |+| Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently. This mattered most for a stage that **removes** — a concurrent share-sheet open mid-conversion is destructive rather than merely early — but the rule is unconditional |+| Extend the archive, if the schema is reader data | A new column the reader owns needs an archive generation too — `work-and-reading-status` is the freshest worked example, 8/9 → 9/10 with `BackupV9Exporter`/`BackupV9Codec` replacing the V8 set outright (Q17, Q34), and `rule-citation-by-uuid` did the same at 7/8 → 8/9 — or a backup silently stops round-tripping it. A stage that only *removes* store columns changes no wire shape: V9 re-recorded no golden. But a change to a record the archive *carries* does, whatever the store schema does — 8/9 was a codec change with no schema stage behind it, which is why Q9 pins the schema number to the store the archive was taken from. Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1` mode (`rule-citation-by-uuid` Q22) rather than by hand | `specs/relational-references/` is the full worked spec for a relational bump. @@ -236,7 +276,7 @@ every freeze and confirm each hit names the new live schema. ### Recorded-store fixtures and the registry -`V8RecordedStoreFixture` seeds a store *through* the frozen snapshot, which is+`V9RecordedStoreFixture` seeds a store *through* the frozen snapshot, which is the only way to get a genuinely previous-version store without committing a binary — and it is the same registry hazard, deliberately taken. What makes it safe is **ordering, not the schema**:@@ -251,11 +291,16 @@ safe is **ordering, not the schema**: the registration. Dropping the flag would abort the process rather than fail a test (`docs/agent-notes/testing.md`). -Underneath that, V9's stored shape is a strict subset of V8's — the stage only-removes — so a live key a stale V8 registration could not answer does not exist.-Do not rely on that at the next bump: a version that *adds* loses the subset-relation, and the ordering above becomes the only thing holding it up. Write the-next recorded-store fixture the same way and say so in its doc comment.+**That safety net is now the only one.** Until V10 there was a second: V9's+stored shape was a strict subset of V8's, because the stage only removed, so a+live key a stale V8 registration could not answer did not exist. The note warned+that "a version that *adds* loses the subset relation, and the ordering above+becomes the only thing holding it up" — **V10 is that version**. `Work` now+declares three columns the frozen V9 snapshot does not, so a snapshot+registration left live *would* meet a key it cannot answer. Nothing but+`V9RecordedStoreFixture`'s create-seed-save-**release** ordering, plus+`make test-core`'s `--no-parallel`, keeps the registry coherent. Write the next+recorded-store fixture the same way and say so in its doc comment. **Two things are harder now than they were for V3 → V5.** @@ -271,27 +316,35 @@ precondition in `specs/retire-migration-chain/` Decision 6, not a formality. ## History — lessons for the next schema bump -### The marker digit has moved five times, and the old digits were kept until they were provably unreachable+### The marker generation has moved six times, and the old ones were kept until they were provably unreachable `"4"` (the relationship pass, `retire-migration-chain`) → `"5"` (`configurable-work-types`) → `"6"` (`character-extraction`) → `"7"` (`relational-references`) → `"8"` (`multi-site-works`) → `"9"`-(`drop-superseded-columns`), which is what `publishReadiness` writes today. Each-bump superseded a statement that had read as permanent: the note said "the-readiness marker holds `"5"`", then `"6"`, then `"7"`, then `"8"`, and each time-the *old* digit stayed in `appOpenableMarkerVersions` rather than being replaced.+(`drop-superseded-columns`) → `"10"` (`work-and-reading-status`), which is what+`publishReadiness` writes today. Each bump superseded a statement that had read+as permanent: the note said "the readiness marker holds `"5"`", then `"6"`, then+`"7"`, then `"8"`, then `"9"`, and each time the *old* value stayed in+`appOpenableMarkerVersions` rather than being replaced. That is the lesson, not the digits: a device that has not launched the new build yet is on the old marker, and the set is what keeps it openable. -The set has been *shrunk* twice, and both times on the same grounds rather than-on a change of mind about the rule: `data-model-cleanups` removed `"4"`–`"6"`-and `drop-superseded-columns` removed `"7"`, each by establishing that the+**`"10"` is where the word "digit" stopped being accurate.** Every generation+through `"9"` was one character, and enough of this note and its readers said+"digit" that the two-character value is worth stating on its own: the marker is a+string, compared as a string, and any code that indexes or length-checks it is+wrong.++The set has been *shrunk* three times, and every time on the same grounds rather+than on a change of mind about the rule: `data-model-cleanups` removed+`"4"`–`"6"`, `drop-superseded-columns` removed `"7"`, and+`work-and-reading-status` removed `"8"` — each by establishing that the population had passed them (one user, every device confirmed on the successor).-The order matters for the next bump: add the digit, ship it, and only retire the-predecessor once every device is known to be past it.+The order matters for the next bump: add the generation, ship it, and only retire+the predecessor once every device is known to be past it. -`V6` was likewise "the live schema" and the plan was `[V5, V6]`; so were V7 and-V8. Every one of those statements was true and every one moved on schedule.+`V6` was likewise "the live schema" and the plan was `[V5, V6]`; so were V7, V8+and V9. Every one of those statements was true and every one moved on schedule. ### Nesting every entity is what makes an in-module snapshot possible @@ -311,7 +364,7 @@ snapshots are inert. zero top-level `@Model` types, and make the top-level names typealiases. The now-deleted `V4MigrationBootstrapTests` seeded genuine frozen-`AsterismSchemaV3` stores and the bootstrap migrated them in-process with no collision;-`V8RecordedStoreFixture` does the same through the frozen V8 snapshot today. That+`V9RecordedStoreFixture` does the same through the frozen V9 snapshot today. That in-process seeding is the second reason to keep the nesting: it is how a conversion test gets a store at the previous version without committing a binary fixture.@@ -331,3 +384,10 @@ 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.++V10 met the same coupling from the other side, and the task list had it wrong:+the plan put the deletion of `V8RecordedStoreFixture` two tasks after the+deletion of the V8 snapshot it opened, and the test target simply would not+compile in between (Q43). The freeze, the previous snapshot's deletion, and the+deletion of every fixture and suite that opened it are **one commit**, not a+sequence.
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 113fb3d..5cf7078 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -173,18 +173,23 @@ unaffected. The message above is the shape the crash took when the mismatched key was `Site.entries` and V3/V4 were the frozen snapshots. Those are long gone; the live-schema is now **V9** and the one frozen snapshot is `AsterismSchemaV8`.--**The hazard got sharper at V9, because V9 is the first schema that *removes*.**-While every snapshot only ever *added*, a stale registration cost you a column-that would not save. Now the live entity is the *narrower* one, so a stale V8-registration strands a key the live classes no longer declare — `Site.works`-was the one that aborted a whole test process (Q29 of-`drop-superseded-columns`). `V8RecordedStoreFixture` opens containers over-`AsterismSchemaV8` in the same process as every suite using the live V9 classes-(`V8RecordedStoreTests`, `CertificationPathTests`, `StoreMetadataTests`,-`MarkerContractTests`), which is why its `write(at:)` releases the snapshot-container before returning — see `docs/agent-notes/schema-migration.md`.+schema is now **V10** and the one frozen snapshot is `AsterismSchemaV9`.++**The hazard has now been sharp in both directions, and V10 has it in the+original one.** V9 was the first schema that *removed*, which made the live+entity the *narrower* one: a stale V8 registration stranded a key the live+classes no longer declared, and `Site.works` aborted a whole test process (Q29 of+`drop-superseded-columns`). V10 **adds** three defaulted `Work` columns+(`workStatusRaw`, `readingStatusRaw`, `verdict`), so the live entity is the wider+one again and a stale V9 registration costs a column that will not save — the+quieter failure, and the harder one to read, because every *other* column+persists. `V9RecordedStoreFixture` opens containers over `AsterismSchemaV9` in+the same process as every suite using the live V10 classes+(`V9RecordedStoreTests`, `V4RecordedStoreTests`, `CertificationPathTests`,+`StoreMetadataTests`, `MarkerContractTests`, `MarkerGenerationTenTests`), which is why its `write(at:)`+releases the snapshot container before returning — see+`docs/agent-notes/schema-migration.md`, where the loss of the live-within-frozen+subset relation is now a lived fact rather than a prediction. Consequences: @@ -234,7 +239,10 @@ for i in 1 2 3; do make test-performance-m4 RUNS=1 > /tmp/m4-run$i.log 2>&1 || t are Req 10.1's settling pass, Req 5.4's three capture-projection arms, Req 5.5's three diagnosis re-derivations, and the full-tier no-op reconcile. Every one has a regression ceiling asserted **outside** the known-issue block, so a run that-drifts further still fails.+drifts further still fails. **V10 confirmed the same eight** — see+`specs/work-and-reading-status/implementation.md` §4, where the three new `Work`+columns cost the capture-projection and diagnosis arms 3–6% and reached no+ceiling. The one that retired is Req 10.1's *observation* pass: V9 deleted `V8PopulationPass` and gated `MembershipReconciler.heal` on the diagnosis, the@@ -312,7 +320,7 @@ confusing. `LibraryValidator.swift:964` with `none assignment has incompatible relationship or provenance`, rejecting the whole library on the next write. The archive fixtures inherit the same rule one step removed: a- `BackupV8Entry` written with a `workID` needs real citation bytes carrying+ `BackupV9Entry` written with a `workID` needs real citation bytes carrying that assignment (`.manual`, or a pattern/URL-rule arm with its cited pattern), because the import materialises the record and validates it. A `workID` beside default `.none` citations fails the *import*, which reads as
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 83f093a..1197a70 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -287,9 +287,11 @@ Sorted by the work's most recent lastSharedAt (the current read floats to the to **Unattached notes** — one fixed system group at the bottom holding workless entries. If a later teach gives an entry a real work, it migrates out automatically (unless intentionally unattached). +**Status marks and abandoned works** (`specs/work-and-reading-status/`). A row wears its work status glyph and then its reading status glyph after the type tag, where either is off its default — `flag.checkered` or `pause.circle` in violet, `checkmark` or `book.closed` in cyan (style guide §8). A work the reader has **abandoned** steps back out of the active library: the whole row renders at the ignored-teach-chip knock-down and its title in the dim colour, and abandoned works sort *last* within each section, keeping the active sort's order among themselves — under every sort, with or without a search query or filters. It is a stable partition applied after the sort, not a sort option the reader can pick. The merge destination picker shows the same marks and the same knock-down on its rows, in its own existing order.+ Toolbar menu includes **New Work** — rarely used, but the destination-creation path for manual assignment must exist. -Search: work titles.+Search: work titles. The options menu's filters (`specs/works-list-options/`) gain two closed-vocabulary dimensions, work status and reading status, each "Any" plus its three values in a fixed order and ANDed with the rest. Both dimensions name themselves in their pills and in the filter empty state — "Work: Finished", "Reading: Finished" — because each enum has a value called `finished`. Every value is offered and stays selected whether or not any work currently carries it. ### 5.3 Settings (gear) @@ -311,19 +313,22 @@ View mode, top to bottom: View mode is a reading surface, not a summary: the notes are the content and the chrome above them is folded down to what names the work (`specs/work-detail-reading-redesign/`). -1. **Header**, on no card at all: the work's full title as a wrapping serif heading; then one row of glyph, site + URL-ID, the **meta line** — `{n} notes ▲ {up} ▼ {down}`, counts only, no drill-down — and a `link` glyph opening the work URL; then the tag row (type + genre tags) as read-only chips. The title is also the **navigation title**, in its `.inline` collapsed form — a large title cannot wrap, so the bar carries the short version and the header carries the whole of it (Q58).-2. **Notes on this work** — generic free-form notes as a plain paragraph under the tags, where non-empty. The lede of the page; it has no header and no card of its own, because an empty field on a read screen invites an edit the screen is not offering.-3. **Open last noted chapter** — primary action, and the screen's one gradient button. Labelled precisely: it opens the newest entry's URL ("back to where I was"), not the latest published chapter.-4. **Characters** — the work's cast as pills. Tapping one expands an inline card beneath the row (not a sheet): the serif name with its aliases as chips, the character note, then each extracted fact on a gutter. A fact whose cited note still exists carries that note's chapter number in the gutter as a link that opens it; a fact from a note with no number gets an arrow and keeps its caption; a fact from the work's own notes or from a note that is gone shows a dash and says which. Full behaviour in `specs/character-extraction/`.-5. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** orders by the URL rule's sequence where that sequence is a site-wide id — so a Royal Road interlude sits between the chapters it was posted between even with no number in its title — then the notes with a chapter number (from a title, or from a sequence that is one) by that number, then the rest; every run oldest-first on a tie (Q4, Q26). **Tapping a row opens that entry's detail screen** (Q56).+1. **Header**, on no card at all: the work's full title as a wrapping serif heading; then one row of glyph, site + URL-ID, the **meta line** — `{n} notes ▲ {up} ▼ {down}`, counts only, no drill-down, followed by the **work status** and the **reading status** as two further items where either is off its default, each a glyph with its name (`specs/work-and-reading-status/`) — and a `link` glyph opening the work URL; then the tag row (type + genre tags) as read-only chips. The title is also the **navigation title**, in its `.inline` collapsed form — a large title cannot wrap, so the bar carries the short version and the header carries the whole of it (Q58).+2. **Verdict** — where the reader is done with the work (reading status finished or abandoned) and wrote something, a paragraph between the tag row and the notes, under a `Verdict` label. Under `abandoned` it says why they stopped, under `finished` how they found it. A verdict typed and then hidden by a status change back to `reading` is *kept in the store and not shown*, here or anywhere else, until the reader is done with the work again.+3. **Notes on this work** — generic free-form notes as a plain paragraph under the tags, where non-empty. The lede of the page; it has no header and no card of its own, because an empty field on a read screen invites an edit the screen is not offering.+4. **Open last noted chapter** — primary action, and the screen's one gradient button. Labelled precisely: it opens the newest entry's URL ("back to where I was"), not the latest published chapter.+5. **Characters** — the work's cast as pills. Tapping one expands an inline card beneath the row (not a sheet): the serif name with its aliases as chips, the character note, then each extracted fact on a gutter. A fact whose cited note still exists carries that note's chapter number in the gutter as a link that opens it; a fact from a note with no number gets an arrow and keeps its caption; a fact from the work's own notes or from a note that is gone shows a dash and says which. Full behaviour in `specs/character-extraction/`.+6. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** orders by the URL rule's sequence where that sequence is a site-wide id — so a Royal Road interlude sits between the chapters it was posted between even with no number in its title — then the notes with a chapter number (from a title, or from a sequence that is one) by that number, then the rest; every run oldest-first on a tie (Q4, Q26). **Tapping a row opens that entry's detail screen** (Q56). Its toolbar carries **Export** (the one read action) and the pencil. -Edit mode holds the title (editable — manual provenance, survives re-parses), the type, the genre tags, the generic notes, the **Work URL** and **URL identity** machinery, and the two structural actions — **Merge into…** and **Delete work** (§9). Merge is reachable only from here. The checkmark writes every field the mode holds, the Work URL among them (Q57) — the URL field has no Save or Clear of its own, and emptying it is the clear; only `Use Suggested URL` remains a separate action, because it approves one projected value rather than whatever the field holds. Saving writes only where a draft differs; a refused save — on either half — keeps the reader in the editor with their draft. The X is edit mode's one dismiss control and returns to view mode; the back chevron is hidden while it is up (Q59).+Edit mode holds, in order, the title (editable — manual provenance, survives re-parses), the type, the **work status**, the **reading status**, the **verdict** (only while the reading status is finished or abandoned), the genre tags, the generic notes, the **Work URL** and **URL identity** machinery, and the two structural actions — **Merge into…** and **Delete work** (§9). Merge is reachable only from here. The two statuses are three-segment capsules rather than menus, each under its own caption, because both contain a segment called "Finished" (style guide §7).++**Finished reading requires a finished work** (`specs/work-and-reading-status/`, Decision 1). "Finished" reading is meant to mean the reader read the whole thing, so selecting it while the work is `ongoing` or `hiatus` raises a confirmation offering to mark the work finished as well, to use `abandoned` instead, or to back out; the same check runs again at the commit whenever the draft pair differs from the stored pair, and backing out there writes nothing. Moving the work *back* off `finished` while the reading status is `finished` silently returns the reading status to `reading` — a mis-tap, not an interrogation — and returning the work to `finished` in the same session restores it. The rule is a UI rule, not a storage guarantee: two devices can each make a valid edit that together violate it, so a stored violating pair is shown as it is and an edit that leaves the pair alone writes it back unchanged. The checkmark writes every field the mode holds, the Work URL among them (Q57) — the URL field has no Save or Clear of its own, and emptying it is the clear; only `Use Suggested URL` remains a separate action, because it approves one projected value rather than whatever the field holds. Saving writes only where a draft differs; a refused save — on either half — keeps the reader in the editor with their draft. The X is edit mode's one dismiss control and returns to view mode; the back chevron is hidden while it is up (Q59). ### 6.1 Merge semantics -Deliberate, target-wins: target keeps identity, display title, type, and URL identity; source generic notes are appended to the target's under a divider (nothing silently lost); genre tags are unioned; if both titles were manual, the source title is recorded in the appended notes. Source entries move to the target with their provenance intact; the source Work is then deleted.+Deliberate, target-wins: target keeps identity, display title, type, **both statuses and the verdict**, and URL identity; source generic notes are appended to the target's under a divider (nothing silently lost); genre tags are unioned; if both titles were manual, the source title is recorded in the appended notes. A source status that is off its default and differs from the target's is listed in the merge preview as a discarded field; a non-empty source verdict that differs from the target's is appended to the merged notes in the audit block, as source notes are, and listed as recorded — the preview says which discarded fields are kept in the merged notes and which are simply dropped (`specs/work-and-reading-status/` Q13). Source entries move to the target with their provenance intact; the source Work is then deleted. ### 6.2 Entry detail (sheet)
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex d5d432a..f296cf6 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -94,16 +94,29 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - **Tags**: type tag = violet tint/border; genre tags = neutral card recipe. 10.5 pt, weight 650. - **Count pill** (works list): cyan text on cyan .12 fill, radius 12. - **Unattached notes group**: dashed border (`rgba(170,200,255,.18)` dark), lower fill, dim ✎ glyph, neutral count pill. *[Works dark]*.-- **Meta line** (work detail): `{n} notes ▲ {up} ▼ {down}` on one `.caption` line beside the site identity — notes count in primary text, semibold; ▲ and its count cyan; ▼ and its count violet. It replaces the three equal pulse cards this section used to name (`specs/work-detail-reading-redesign/`): the counts are metadata, and three cards made them the page. It wraps rather than truncates — a truncated count is a wrong count.+- **Meta line** (work detail): `{n} notes ▲ {up} ▼ {down}` on one `.caption` line beside the site identity — notes count in primary text, semibold; ▲ and its count cyan; ▼ and its count violet. It replaces the three equal pulse cards this section used to name (`specs/work-detail-reading-redesign/`): the counts are metadata, and three cards made them the page. It wraps rather than truncates — a truncated count is a wrong count. Two further items follow the counts where they have something to say (`specs/work-and-reading-status/`, Req 4.1): the **work status** as a `Label` of its glyph and name in violet, then the **reading status** in cyan. A status sitting on its default — `ongoing`, `reading` — adds no item at all, so the line is unchanged for every work the reader has said nothing about.+- **Status marks** (works list row, `specs/work-and-reading-status/`): a work's two statuses appear on a row as bare glyphs *after the type tag*, work status first (violet, the type tag's own hue) then reading status (cyan, the count pill's) — no third accent hue enters the language for them (§11, Q27). They are drawn in `constellationPill`'s own text font, `.caption` semibold, so a mark is never taller than the tag beside it whatever the Dynamic Type size. Default values draw nothing. Each glyph carries an accessibility label naming its dimension and value — "Work: Finished", "Work: On hiatus", "Reading: Finished", "Reading: Abandoned" — because both enums have a value called "Finished" and an unqualified row would read "Finished, Finished".+- **Abandoned knock-down** (works list row and the merge destination picker): a row whose reading status is `abandoned` renders at `ConstellationRecipes.knockdownOpacity` (0.5) with its title in `secondaryText` — **the design language's one knock-down amount, the ignored teach chip's, not a second constant**. In the works list an abandoned row also sorts last within its section under every sort (the merge picker keeps its own order). The dimming is colour and opacity, neither of which every reader has, so the row's title element carries "Reading: Abandoned" in its own accessibility label. A row that is *both* abandoned and wearing a removed type's `dimmedTypeTag` double-dims that pill to a quarter opacity; that is accepted rather than clamped, because a clamp would need a third opacity the guide does not have (Q58). - **Selected pill**: a pill that has been opened — work detail's cast pill, with its chevron pointing up — takes the type-tag recipe lifted: violet .26 fill, violet .6 border, no glow (`ConstellationPillKind.selectedTypeTag`, Q11/Q14). Selection changes no other pill. - **Spine row** (work detail's chapter notes): no card. A gutter (≥ 44 pt, growing with its label) holds a 10 pt rating dot — cyan filled for ▲, violet for ▼, a 1.5 pt dim ring when unrated — over the chapter number in SF Mono dim (nothing beneath the dot where the note has no number — an interlude, or a site whose URL carries only an id, Q25), and a 1 pt `cardBorder` rail runs through the dots from the first row's to the last row's. Beside it: the row title (13 pt semibold, one line, truncating) with the date trailing in `.caption2` dim, then the whole note at 15 pt with no line limit. The rail is drawn in the row container's background, outside the row's button, so pressing a row does not dim it.-- **Segmented capsule (two or three short labels), or a menu**: use a **capsule** when a choice has two or three short labels and all of them must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1) and Stats' `Week | Month | All time` unit (`specs/stats-period-navigation/`, Q11). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. Placement follows what the control governs: **beside its section header** where it has one, dropping to its own line beneath that header where the two do not fit — work detail's sort (Q21) — and **centred above what it governs** where it stands alone, with no header to sit beside, which is Stats' unit toggle and its chevron row (`specs/stats-period-navigation/`, Q33). Either way, at the largest accessibility sizes it stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). **One implementation, not a copy per screen**: `ConstellationSegmentedControl` in `ConstellationKit` — values, a selection binding, a title and an accessibility identifier per value, and a required container label naming what the segments choose (`stats-period-navigation`, Q28). Use a **`Menu`** instead when the choice has more than three options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods before this control replaced them (`specs/stats-page/`, Q31).+- **Segmented capsule (two or three short labels), or a menu**: use a **capsule** when a choice has two or three short labels and all of them must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1) and Stats' `Week | Month | All time` unit (`specs/stats-period-navigation/`, Q11). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. Placement follows what the control governs: **beside its section header** where it has one, dropping to its own line beneath that header where the two do not fit — work detail's sort (Q21) — and **centred above what it governs** where it stands alone, with no header to sit beside, which is Stats' unit toggle and its chevron row (`specs/stats-period-navigation/`, Q33). Either way, at the largest accessibility sizes it stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). **One implementation, not a copy per screen**: `ConstellationSegmentedControl` in `ConstellationKit` — values, a selection binding, a title and an accessibility identifier per value, and a required container label naming what the segments choose (`stats-period-navigation`, Q28). Use a **`Menu`** instead when the choice has more than three options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods before this control replaced them (`specs/stats-page/`, Q31). **Give the capsule a visible caption where the segment names alone are ambiguous**: work detail's edit mode stacks two of these controls, and both contain a segment called "Finished", so each sits under a `.caption` semibold `secondaryText` caption — "Work status", "Reading status" — inside the card, with the same text as the control's `containerLabel` (`specs/work-and-reading-status/`, Q26). The verdict field below them takes the same caption recipe for its prompt ("How was it?" / "Why did you stop?") rather than as the placeholder, because a placeholder vanishes the moment the reader types and the prompt is the only thing that tells the two verdicts apart (Q16). - **Provenance disclosure**: quieter-than-card fill (.035), SF Mono 11 pt, dim, labels slightly brighter. Collapsed by default. *(No longer used on entry detail — the block was removed from that screen entirely, `specs/polish-and-export/` Q52. The recipe stands for any diagnostics surface that needs it.)* ## 8. Iconography ✦ (four-pointed star) is the app's mark — used for: Works tab, section-header bullets, banner icon, new-site banner icon. Keep it sparse: never more than one ✦ per component. Other glyphs (◷, ⚙, ✎, ?, ▲, ▼, ‹, …, ↗) come from SF Symbols equivalents at implementation time. The Stats tab is named directly by its symbol, `chart.bar` — it says what the page is and matches the graph it opens onto (`specs/stats-page/`, Q17). No emoji anywhere. +**The four status symbols** (`specs/work-and-reading-status/`, Q27), each used for exactly one thing and nowhere else in the app:++| Symbol | Means | Hue |+|---|---|---|+| `flag.checkered` | work status **Finished** — the author is done | violet |+| `pause.circle` | work status **On hiatus** — no new chapters for now, but it may resume | violet |+| `checkmark` | reading status **Finished** — the reader read the whole work | cyan |+| `book.closed` | reading status **Abandoned** — the reader put it down | cyan |++`ongoing` and `reading` are the defaults and have **no symbol**: a mark on every row would say nothing. `checkmark.circle` and `xmark.circle` were both already taken, which is why the finished-reading mark is the bare `checkmark`; none of the four drags an existing success or close meaning in with it. Hiatus and a finished work get distinct glyphs deliberately — both mean "no new chapters", but only one of them may resume (Q10).+ ## 9. Motion (v1 minimal) - Sheets: system sheet presentation; no custom transitions.
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 41c46d8..b6f88a1 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -37,6 +37,7 @@ | [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. | | [Works List Options](#works-list-options) | 2026-09-03 | Done — all 10 tasks implemented 2026-09-03 across three phases (pure logic, the list, fixture and journeys); `make test-quick` and `make test-ui` green (pre-existing M4Scale sim trio excepted) with no new warnings. The Mac toolbar menu's rendering remains the owner's manual check | Smolspec (T-2302). A four-way sort for the Works list (Newest first, Oldest first, A to Z, Z to A) and one-value filters by type, tag and site, from one toolbar menu on iPhone, iPad and Mac. The sort persists on the device; filters are view state with the search query's lifetime. Empty works keep their trailing section under the date sorts and join one section under the title sorts. App-layer only, on `WorkSnapshot`; amends `polish-and-export` Req 4.1's fixed-ordering clause. | | [Background Export](#background-export) | 2026-09-02 | Done — all 12 tasks implemented 2026-09-03 across four phases (Core, App, Extension, Documentation). Device verification remains the owner's: the eight-step runbook in `runbook.md`, `Development` first and `Personal` last after a container download, every step approved at the moment of running | Full spec (T-2052). An iOS app-refresh background task that lets the app's CloudKit mirror export captures the share extension committed with mirroring off, so a share on the phone reaches other devices without the app being opened. The extension leaves one empty UUID-named marker file per commit; a marker is settled by any successful export whose start is later than the marker's creation, checked against a persisted export start before any wait. The pass is a mode of `AppLibraryModel`: it reuses a live library or opens and shuts one of its own, and a foreground open pre-empts it. Refresh-only, 20 s budget, iOS only (BackgroundTasks does not exist on macOS); the Mac keeps the foreground marker clearing. |+| [Work and Reading Status](#work-and-reading-status) | 2026-09-04 | Done — all 20 tasks implemented 2026-09-05 across eight phases and seven design-review rounds (Q42–Q68); verification recorded in `verification-run.md`. Owner-side steps remain, all in `prerequisites.md`: an 8/9 archive from every device is the only rollback from the first V10 install, both devices must be updated before either reopens the library, a `Development` run publishes the three new fields to the dev CloudKit container, and the device checks Q58 (double-dimmed type tag), Q67 (the abandoned knock-down, which XCUITest cannot read) and Q65 (the finished-reading dialog) are eyes-only | Full spec (T-2306). Two reader-entered statuses on every work — the work's own (ongoing, finished, hiatus) and the reader's (reading, finished, abandoned) — plus a verdict text once the reader is done. Three defaulted columns under schema V10 with the V8 stage retired, markers `"9"` → `"10"`, and the archive at 9/10; the fields ride the `genreTags` authored-content chain through duplicates and merge. Finished reading requires a finished work, enforced on the picker and again at commit (Decision 1). Abandoned works dim and sort last in the Works list; both statuses are filters with dimension-qualified pills. | --- @@ -627,3 +628,19 @@ Full spec (T-2052). An extension capture reaches CloudKit only when the app is n - [prerequisites.md](background-export/prerequisites.md) - [runbook.md](background-export/runbook.md) - [implementation.md](background-export/implementation.md)++---++## Work and Reading Status++**Created:** 2026-09-04 · **Status:** Done — all 20 tasks implemented 2026-09-05 across eight phases (schema and markers, the write chain, duplicates and merge, the archive, the Works list and filters, work detail and the finished-reading rule, the UI journeys, documentation and verification) and seven design-review rounds, which added Q42–Q68 to the decision log. `make test-core`, `make test-quick` (with the Mac build), `make test-ui`, `make test-ui-ipad` and `make test-performance-m4` are recorded in `verification-run.md`. What is left is the owner's, and all of it is in `prerequisites.md`++Full spec (T-2306). Every work carries a work status (ongoing, finished, hiatus; default ongoing) and a reading status (reading, finished, abandoned; default reading), both reader-entered, with an optional verdict text shown only while the reading status is finished or abandoned. Three defaulted columns on `Work` under schema V10 (Q18 retires the V8 stage: plan `[V9, V10]`, marker `"10"` — the first generation spelled with two characters), read through the tolerant enum accessor and refused by name on export; the archive moves to 9/10 with the 8/9 importer deleted (Q17), and import writes the archive's verdict verbatim because `updateWork` is the single normaliser (Q33, Q55). The fields join `WorkAuthoredContent`, so a collapse never drops a status and a differing verdict forms a review variant (Q14); merge keeps the target's values and records a losing verdict in the audit block, with the preview's "recorded in merged notes" copy made truthful (Q13, Q41) and the destination label's status clauses placed before the refusal rather than after it (Q59). Finished reading requires a finished work — a dialog offers to mark the work finished or use abandoned, on the picker and again at commit when the pair changed (Decision 1, Q20); the resolver takes the presented prompt and cancel is a separate synchronous call (Q62), and on iOS 26 the dialog presents as an anchored popover whose third outcome is the platform's dismiss region rather than a drawn Cancel (Q65, Q66). Edit mode gets two captioned segmented capsules and a verdict field (Q26); the meta line and the Works row get four glyphs (Q27); abandoned rows dim and sort last under every sort (Q31); two closed-vocabulary filters join the options menu with dimension-qualified pills (Q23, Q30). Parameter defaults are decided per failure direction: required on `WorkMetadataDraft` and `WorkVariantSide`, where an omission silently overwrites or drops reader text (Q40, Q52), defaulted on `WorkEditBasis`, where an omission can only cause a visible refusal (Q47).++- [requirements.md](work-and-reading-status/requirements.md)+- [design.md](work-and-reading-status/design.md)+- [tasks.md](work-and-reading-status/tasks.md)+- [decision_log.md](work-and-reading-status/decision_log.md)+- [prerequisites.md](work-and-reading-status/prerequisites.md)+- [verification-run.md](work-and-reading-status/verification-run.md)+- [implementation.md](work-and-reading-status/implementation.md)
diff --git a/specs/retire-migration-chain/library-graph-baseline.txt b/specs/retire-migration-chain/library-graph-baseline.txtindex 4bea437..8672e31 100644--- a/specs/retire-migration-chain/library-graph-baseline.txt+++ b/specs/retire-migration-chain/library-graph-baseline.txt@@ -13,7 +13,14 @@ # rule-citation-by-uuid (T-2281) cites a rule by UUID alone, so no citation # carries a version. Re-recorded by deleting that key from the one blob that # held it, not by regenerating the file.-format 6+# format 7 is schema V10 (work-and-reading-status, T-2306): every work line+# gains workStatusRaw, readingStatusRaw and verdict after titleProvenanceRaw.+# The seed sets none of them, so every work carries the V9 -> V10 stage's+# defaults — which is the point: the three fields on each line are the+# baseline's own statement that a converted row reads ongoing/reading/empty.+# Re-recorded by adding those three fields to each work line and reviewing+# the diff line by line (Q35), not by regenerating the file.+format 7 counts entries=5 works=1 sites=3 titlePatterns=1 urlRulePatterns=1 workTypes=3 memberships=1 distinctPairs=0 site hostname="alpha.test" displayName="Alpha Reader" modeRaw="untaught" junkSuffixRule=nil site hostname="beta.test" displayName="Beta Serials" modeRaw="taught" junkSuffixRule=nil@@ -23,7 +30,7 @@ urlRulePattern id=A2000000-0000-4000-8000-000000000002 site="beta.test" version= workType id=D0000001-0000-4000-8000-000000000001 name="novel" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000 workType id=D0000002-0000-4000-8000-000000000002 name="webtoon" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000 workType id=D0000003-0000-4000-8000-000000000003 name="article" nameModifiedAt=0.000 stateRaw="active" stateModifiedAt=0.000 canonicalID=nil createdAt=0.000 modifiedAt=0.000-work id=A3000000-0000-4000-8000-000000000003 displayTitle="Beta Serial" lastParsedTitle="Beta Serial" genericNotes="notes on the serial" workTypeID=A4000000-0000-4000-8000-000000000004 genreTags=["action","drama"] titleProvenanceRaw="parsed" createdAt=1800000000.000 modifiedAt=1800000000.000+work id=A3000000-0000-4000-8000-000000000003 displayTitle="Beta Serial" lastParsedTitle="Beta Serial" genericNotes="notes on the serial" workTypeID=A4000000-0000-4000-8000-000000000004 genreTags=["action","drama"] titleProvenanceRaw="parsed" workStatusRaw="ongoing" readingStatusRaw="reading" verdict="" createdAt=1800000000.000 modifiedAt=1800000000.000 entry id=B1000000-0000-4000-8000-000000000011 site="alpha.test" work=nil hostname="alpha.test" captureTitle="An Alpha Capture" captureTitleSourceRaw="host" rawURLString="https://alpha.test/read/1" canonicalURLString=nil entryIdentityKey="https://alpha.test/read/1" conservativeIdentityKey="https://alpha.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle=nil note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"none\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"none\":{}}}" entry id=B2000000-0000-4000-8000-000000000012 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter One" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/1" canonicalURLString=nil entryIdentityKey="https://beta.test/read/1" conservativeIdentityKey="https://beta.test/read/1" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter One" note="a reader's note" ratingRaw="up" firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"manual\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}" entry id=B3000000-0000-4000-8000-000000000013 site="beta.test" work=A3000000-0000-4000-8000-000000000003 hostname="beta.test" captureTitle="The Beta Serial :: Chapter Two" captureTitleSourceRaw="host" rawURLString="https://beta.test/read/2" canonicalURLString=nil entryIdentityKey="https://beta.test/read/2" conservativeIdentityKey="https://beta.test/read/2" identityBasisRaw="conservative" urlWorkIdentity=nil chapterSequence=nil chapterTitle="Chapter Two" note="" ratingRaw=nil firstCapturedAt=1800000000.000 lastSharedAt=1800000000.000 modifiedAt=1800000000.000 intentionallyUnattached=false citationsData="{\"chapterTitle\":{\"kind\":\"pattern\",\"patternID\":\"A1000000-0000-4000-8000-000000000001\"},\"identity\":{\"rawURL\":{}},\"workAssignment\":{\"manual\":{}}}"
diff --git a/specs/work-and-reading-status/decision_log.md b/specs/work-and-reading-status/decision_log.mdnew file mode 100644index 0000000..9fe75ea--- /dev/null+++ b/specs/work-and-reading-status/decision_log.md@@ -0,0 +1,146 @@+# Decision Log: Work and Reading Status++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-09-04 | Full spec workflow, not smolspec | Two new stored columns mean a V10 schema bump on a CloudKit-shared model — the expensive-to-reverse trigger — and the finished-reading rule and list behaviour are user-owned choices the code does not settle |+| Q2 | 2026-09-04 | Spec directory `specs/work-and-reading-status/` | Matches the Transit ticket title and names both statuses |+| Q3 | 2026-09-04 | Both statuses are reader-entered; nothing is scraped from a work's page | The capture path takes URL and title only, and a reader knows the state; the default `ongoing` covers the common case for free |+| Q4 | 2026-09-04 | Work status has no `abandoned` value | A reader cannot usually tell an abandoned work from a finished one; `hiatus` covers the temporary case |+| Q5 | 2026-09-04 | `hiatus` counts as not finished for the finished-reading rule | The user's framing: hiatus is a temporary finished state, and a reader caught up on a hiatus work has not finished it |+| Q6 | 2026-09-04 | One verdict field serves both done-reading statuses, with a prompt that changes by status | Same shape of text either way (a closing thought); two columns would double the plumbing for no reader benefit |+| Q7 | 2026-09-04 | A work reverting from `finished` to `ongoing`/`hiatus` moves a `finished` reading status back to `reading`; the verdict text stays stored but hidden | Blocking the work change would punish a mis-tap; destroying the verdict would lose reader text over a status flip |+| Q8 | 2026-09-04 | Captures never change either status | Silent status flips from the share extension would surprise, and the extension stays out of write-side rules; the reader flips it by hand |+| Q9 | 2026-09-04 | Finished-reading rows get a small done marker in the Works list | User choice; abandoned rows are greyed, finished rows only marked |+| Q10 | 2026-09-04 | Finished-work and hiatus use two distinct glyphs | They read differently at a glance: both mean "no new chapters", but hiatus may resume |+| Q11 | 2026-09-04 | Abandoned-last ordering and greying apply to the Works list only | Recent is a chronological entry feed; reordering it by work status would break its meaning |+| Q12 | 2026-09-04 | Stats, markdown export and share-sheet display are out of scope | Not part of the ask; each is an independent follow-up if wanted |+| Q13 | 2026-09-04 | Merge keeps the target's statuses and verdict; a differing source status is listed as a discarded field, a non-empty source verdict is appended to the merge audit block like source notes, and the preview's wording distinguishes recorded from dropped fields | Matches how merge keeps the target's type; a "most advanced value" rule would need a tie-break for two differing non-default values. Revised after review: the preview today says every discarded field is "recorded in merged notes", which is only true of titles, URLs and notes, so listing statuses required fixing the copy and the verdict, being reader text, goes to the audit block |+| Q14 | 2026-09-04 | A non-default status and a non-empty verdict count as reader-authored content for duplicate reconciliation | Consistent with tags and type: a collapse can never drop a status the reader set, at the cost of an occasional review card |+| Q15 | 2026-09-04 | No requirement for pre-feature builds syncing against a converted library | One user who updates every device together; the added fields are additive in CloudKit but nothing on the simulator can verify it, so the requirement would buy nothing testable |+| Q16 | 2026-09-04 | Verdict prompts "Why did you stop?" (abandoned) and "How was it?" (finished) as a label above the field; detail label `Verdict` | User confirmed the drafted wording; a label rather than a placeholder because the prompt is what tells the two meanings apart and a placeholder vanishes once typed |+| Q17 | 2026-09-04 | A pre-feature (8/9) archive is refused by this build; the new generation is a new version pair and a fresh export after updating is the restorable one | The archive policy since `rule-citation-by-uuid` Q8/Q9 is one supported pair per build with older readers deleted; keeping the 8/9 importer would reverse it for one feature. User chose to keep the policy |+| Q18 | 2026-09-04 | The V8 → V9 stage is retired at V10: plan `[V9, V10]`, marker `8` no longer openable | User confirmed every device has run the V9 build (marker `9`) on 2026-09-04, which `specs/retire-migration-chain/` Decision 6 sets as the precondition for retiring a stage |+| Q19 | 2026-09-04 | An unknown stored status reads as its default and a committed edit writes what the picker shows; export refuses an unknown value by name | The tolerance policy in `Models.swift` (`ToleratedEnum`) covers reading only — "writing is unaffected" — and `updateWork` already writes every draft field to every row, so preserving an unknown raw value through an unrelated edit would need a draft representation for a value the picker cannot name. Export already refuses every unrepresentable enum value, so the dead end is the existing one and is stated as Req 8.3 |+| Q20 | 2026-09-04 | The finished-reading rule is enforced at commit, gated on the draft pair differing from the stored pair, as well as on the two picker transitions | Review found the first draft's "next edit that touches either status" false: a stored ongoing/finished pair edited to hiatus touched a status and fired nothing. Gating on the pair differing keeps a notes-only edit from rewriting a tolerated stored pair |+| Q21 | 2026-09-04 | The review surfaces (duplicate review card, merge preview) may show a verdict hidden under `reading` | Q14 makes a non-empty verdict authored content, so two rows can differ on text Q7 hides; without the exception the review card would show two identical-looking variants. The alternative, counting the verdict as authored only while shown, would have overturned Q14 |+| Q22 | 2026-09-04 | The merge destination picker's rows show the same glyphs and reduced emphasis as the Works list; its order is unchanged | The picker shares `WorkRow`; a flag to suppress the marks would be code for a worse outcome, since seeing that a destination is abandoned is useful before merging into it |+| Q23 | 2026-09-04 | Filter pills, the empty state and glyph accessibility labels name the dimension ("Work: Finished", "Reading: Finished") | Both dimensions have a value called `finished` and the pill row prints bare names, so an unqualified label would read "Finished, Finished" |+| Q24 | 2026-09-04 | No bootstrap timing criterion for the V9 → V10 conversion | The "existing bootstrap budget" the first draft cited does not exist; the stage adds three defaulted scalars under a lightweight migration, and a benchmark for it would cost more than it buys |+| Q25 | 2026-09-04 | Editing happens in the detail screen's inline edit mode; the requirements name no edit sheet | Review found there is no sheet: `WorkDetailView` swaps edit sections into the same list, and "committing" is the navigation bar's confirmation |+| Q26 | 2026-09-05 | The two status choices in edit mode are `ConstellationSegmentedControl` capsules, not `Picker`s | Style guide §7: a choice of two or three short labels uses the capsule so the state is visible without opening anything; the Type control is a `Picker` only because its vocabulary is open. User confirmed |+| Q27 | 2026-09-05 | Glyphs: finished work `flag.checkered`, hiatus `pause.circle`, finished reading `checkmark`, abandoned `book.closed`; work glyphs violet (the type tag's hue), reading glyphs cyan (the count pill's) | None of the four is used elsewhere in the app, so no existing success or close meaning is overloaded; `checkmark.circle` and `xmark.circle` are both taken. No third hue (§11). User confirmed |+| Q28 | 2026-09-05 | The tests' canonical unrecognised marker text moves from `"10"` to `"99"` | Five suites use `"10"` as the unknown digit today; once `"10"` is the live generation they would be testing the wrong thing |+| Q29 | 2026-09-05 | Every existing Work `VariantID` changes once, and a group's carrier may move; accepted without a migration | The id hashes `orderComponents`, which gain three parts and also feed the representative ordering. The ids live in the in-memory ledger and in torn-write disclosures; a stale disclosure re-presents the sheet once. Same cost `configurable-work-types` accepted for the type token |+| Q30 | 2026-09-05 | Status filter dimensions are not pruned and carry no entry in `WorksFilterOptions`; the menu iterates `allCases` | Closed vocabularies never vanish from the snapshot, which is the only reason `pruned` exists; Req 6.3 requires every value offered and kept |+| Q31 | 2026-09-05 | Abandoned-last is a stable partition at the end of `WorksSort.apply`, before the view splits sections by emptiness | Both steps are stable, so "last within each section under every sort" follows from one line and no section-aware code; the merge picker does not go through `WorksSort` |+| Q32 | 2026-09-05 | The row's status words go into `WorksRowPresentation.openLabel` and `WorkMergeView.destinationLabel` as trailing `. Work: …` / `. Reading: …` clauses, and onto the `work-title` element's own label | `WorkRow` is one button, so child glyph labels are never spoken; the label is the only channel, and the merge picker has its own. The separator is `. ` because the label already joins several hostnames with `, `; the UI-test title parser reads before ` from ` and is unaffected |+| Q33 | 2026-09-05 | The verdict is trimmed on write in `updateWork` and nowhere else; no length limit | Same place `normalizeTags` runs; a status enum needs no normalisation |+| Q34 | 2026-09-05 | Archive types and files rename `BackupV8*` → `BackupV9*`; `BackupV9Work`'s three new fields are required, not defaulted | `rule-citation-by-uuid` Q14 policy: a new generation replaces the old set outright. The only accepted archive is one this build wrote, so a default on decode would only mask a malformed archive |+| Q35 | 2026-09-05 | `library-graph-baseline.txt` moves to `format 7` and is re-recorded with its diff reviewed | The baseline serialises `Work` column by column; three new columns change every work line |+| Q36 | 2026-09-05 | Enum names, symbols, hues, accessibility labels and the verdict prompts live in `Asterism/Asterism/Views/WorkStatusPresentation.swift` beside `WorkTypePresentation.swift` | The one existing presentation table for a work field lives there; `Rating`'s labels are a private view helper, which is the pattern *not* to copy because the row label, the filter pills and the dialog all need the same spelling |+| Q37 | 2026-09-05 | The finished-reading prompt is a `presenting:` value (`FinishedReadingPrompt`) resolved through one model method, and the commit-time check runs first in `commitEditing()` | The four existing dialogs record that SwiftUI runs the `isPresented` setter before the button action, so the confirmed value must travel on the presented struct; running the check before the URL step means a cancel leaves every draft intact |+| Q38 | 2026-09-05 | Req 7.3 amended: only a non-default differing source status is listed as discarded, and only a source verdict differing from the target's is recorded | A default contributes nothing (Q14), and recording a verdict identical to the target's would be noise in the notes |+| Q39 | 2026-09-05 | A work status returned to `finished` before commit restores a reading status the same edit session auto-reverted | Q7's rationale is not punishing a mis-tap; without the restore the reader would have to re-select `finished` and meet no dialog, since the work is finished again, but still lose one tap. Cleared by any other reading change |+| Q40 | 2026-09-05 | `WorkMetadataDraft`'s three new parameters have no defaults | `updateWork` writes all three to every row, so a draft built without them would silently reset a reader's statuses with no compiler error; the same hazard class as `isBare`. Defaults stay on `WorkSnapshot` and `WorkAuthoredContent`, which only read |+| Q41 | 2026-09-05 | `WorkVariantUnion.fold` records a losing verdict in the audit block on both its callers, merge and duplicate resolution | The fold is shared, and losing notes already flow to the survivors' notes on both paths; a verdict is reader text of the same kind. The block's gate gains a verdict arm so a side differing by verdict alone still produces one |+| Q42 | 2026-09-05 | `WorkType` is retired from `AsterismCore`; the suites that used it as sugar carry a private test-local copy, and `removedMachineryStaysRemoved` lists it | Its doc comment justified it as the raw set a pre-feature build wrote into `Work.typeRaw`, a column dropped at V9 whose last referent was the frozen V8 snapshot deleted in Phase 1; no production code named it |+| Q43 | 2026-09-05 | `V8RecordedStoreFixture` was deleted with the V8 snapshot in task 3, not in task 6 as listed | The test target cannot compile with a fixture opening a deleted schema; task 6 then wrote the V9 pair as planned |+| Q44 | 2026-09-05 | Three suites the task list did not name moved a generation: `StoreMetadataTests` seeds through the V9 fixture at `9.0.0` → `10.0.0`, and the `createReady*` / `seedCertifiedLibrary` helpers in `BackupImportTransactionTests` and `WriteSiteRelationshipTests` write marker `"10"` | Those helpers seed over a *live-schema* store, so the current generation is the correct value for helpers named "ready" and "certified"; the old `"8"` incidentally exercised the lagging arm, which `MarkerGenerationTenTests` now covers directly |+| Q45 | 2026-09-05 | `onlyTheFrozenSnapshotDeclaresTheDroppedColumns` pins an empty `naming` set, and the store half of `WrongHostWorkURLCompatibilityTests.versionsAreUnchanged` is deleted | With V8 gone no source file may name a dropped column, a stronger pin than the old `["AsterismSchemaV8.swift"]`; the version pin re-stated `FrozenLibraryPathTests` while its comment said the versions were whatever the plan says, and proved nothing about the wrong-host heal. Its archive half stays until task 13 |+| Q46 | 2026-09-05 | Task 2 cites Req 8.3 but export refusal of an unknown status raw is tested by task 12; Phase 1 is not releasable on its own because a schema-10 store still exports a `8/9` archive stamp until task 13 | Traceability note only; the `rule-citation-by-uuid` Q9 rule that the schema number names the store the archive came from holds again once the archive set renames |+| Q47 | 2026-09-05 | `WorkEditBasis`'s three new parameters are defaulted, unlike `WorkMetadataDraft`'s (Q40) | The two fail in opposite directions: an omitted draft field writes a default over a reader's value, an omitted basis field can only fail to match a non-default survivor — a visible refusal, never a silent overwrite. A positive-path redirect test over a marked survivor keeps the over-refusal honest, and the detail model's nil-snapshot fallback basis states all three explicitly |+| Q48 | 2026-09-05 | "A row carrying only the default statuses" in Req 7.2 means a bare row, not a per-field absence: a defaults row that is non-bare for another reason still forms a variant | Every status has its own `orderComponents` slot and only wholly bare rows are excluded from the variant set, exactly as an empty `genreTags` behaves |+| Q49 | 2026-09-05 | A save from a screen loaded before another device changed a status writes the loaded values back — last write wins, and the edit basis is not consulted while the row is alive | The design's error handling names last-write-wins for every field; Req 7.4's conflict is the post-collapse redirect, not a live-row race. `updateWork` writing all three to every row is what makes the loaded values the only safe stand-in until task 17 sends drafts |+| Q50 | 2026-09-05 | Req 2.6's backup-import arm (a materialised work reads the archive record's statuses, or the defaults when a status-less record is still reachable) is owned by task 13 | Design names `BackupImportTransactionTests` for it and no task listed it; task 13 carries the fields through the archive and is the moment the record shape is settled |+| Q51 | 2026-09-05 | `WorkMergeOutcome` carries the target's three values although no view renders them yet | Contract parity with the fold, and the design reserves them for the preview's summary line; the alternative is adding them, dropping them and adding them again |+| Q52 | 2026-09-05 | `WorkVariantSide`'s three parameters are required, not defaulted | An omitted `verdict` silently drops a losing verdict from the audit block, and on the resolution path the losing row is then deleted — permanent loss of reader text with no compiler error, Q40's hazard class rather than Q47's visible refusal |+| Q53 | 2026-09-05 | The audit block's `Verdict:` line is escaped the way the merged-from header is | The verdict is multi-line (Req 2.3); an unescaped `\n\n` would split the structured region and a `--- Merged from:` inside it would forge a block boundary |+| Q54 | 2026-09-05 | `DuplicateResolutionView`'s status arms carry their own words until task 15 lands, then delegate to the Q36 presentation table | Task 11 is stream 1 and the table is stream 2's; the sheet is a fourth consumer of one spelling, so the intermediate second table must not outlive the feature |+| Q55 | 2026-09-05 | Import writes the archive's verdict verbatim; trimming stays in `updateWork` only (Q33) | An archive this build wrote already holds a trimmed verdict, so the round trip is exact; a hand-edited archive with a whitespace verdict must also forge the checksum and is outside the threat model, and a second trim site would end `updateWork`'s role as the single normaliser |+| Q56 | 2026-09-05 | Req 2.6's "a backup import of a record without statuses" closed at task 13: the wire fields are required (Q34) and an 8/9 file is refused by version (Q17), so no status-less record reaches the importer; the arm's test asserts a record on the defaults lands on the defaults | No import path mints a `Work` from anything but a `BackupV9Work`; the requirement text is left as written mid-feature and this row is the pointer a reader hunting for that path needs |+| Q57 | 2026-09-05 | The degraded-refusal arms use an unknown `workStatusRaw` and an empty `readingStatusRaw`, one per field, where the design named both on `workStatusRaw` | Both `require` lines are exercised; an empty raw is refused by name on export while the accessor reads it as the default, the asymmetry the design states |+| Q58 | 2026-09-05 | An abandoned row whose type is removed double-dims its type tag to a quarter opacity; accepted rather than clamped | Req 5.2 mandates a row-level knock-down and the removed-type pill carries its own (`dimmedTypeTag`); a clamp would need a third opacity constant the style guide does not have, and the fixture keeps the two cases on different works. The owner's device check at task 20 looks at that pairing once |+| Q59 | 2026-09-05 | The merge destination label carries its status clauses after the note count and before the refusal, not trailing as Q32 states for the row label, and the refusal follows a full stop when a clause precedes it | The refusal is a finished sentence ending in a period, so a literal append doubles the full stop; the no-clause label stays byte-identical to the pre-feature string |+| Q60 | 2026-09-05 | `accessibilityLabel` stays the name of the qualified "Work: On hiatus" string although the filter pill and the resolution sheet show it visibly | Q23 makes the visible and the spoken form one string; renaming mid-feature touches four call sites and three suites for no behaviour change. Task 19 may rename it to `qualifiedName` if the doc rewrite makes the misnomer worse |+| Q61 | 2026-09-05 | Req 5.1 (glyph after the type tag, no taller row) is covered by a task 18 journey rather than a unit test | The placement is a layout fact `WorkStatusPresentationTests` cannot see; tasks 14 and 15 cite 5.1 without a test, so the journey carries it |+| Q62 | 2026-09-05 | `resolveFinishedReadingPrompt(_ prompt:choosing:) async` takes the presented prompt, Cancel is the sync `cancelFinishedReadingPrompt()`, and `FinishedReadingResolution` has no cancel case | Q37's own reason: SwiftUI clears the presented value before the button action runs, so `thenCommits` must travel on the prompt, exactly as `chooseDeletion(_:disposition:)` / `cancelDelete()` do; the design's one-parameter spelling is corrected by task 19 |+| Q63 | 2026-09-05 | The verdict draft is compared untrimmed in `hasUnsavedChanges`, like the title and unlike the URL | `updateWork` is the single normaliser (Q33) and the reload after a save re-seeds the trimmed text, so no phantom change survives; a trimmed comparison would swallow a whitespace-only edit the URL field has its own reason to swallow |+| Q64 | 2026-09-05 | The finished-reading dialog message is "Finished reading means you read the whole work, and this one is not marked finished." plus "Nothing has been saved yet." on the commit arm and "Choose what to record." on the picker arm | The design pins the title and the button labels only; the message states the rule and the stakes in the deletion prompt's register, and the buttons name their own outcome |+| Q65 | 2026-09-05 | The finished-reading dialog's third action on the phone is the platform's dismiss region, not a drawn Cancel | promoted to Decision 2 |+| Q66 | 2026-09-05 | `declineConfirmationDialog`'s fallback tap now picks a point in a margin the popover leaves, and only when the popover covers its old default point; every existing caller taps where it always did | At accessibility5 the popover spans the old fallback point, so the "decline" was pressing "Mark the work finished too" and the XXXL journey was green with both statuses written; the post-cancel `isSelected` checks now prove the dismissal cancelled. The band above the popover is the status bar and never reaches the dimming layer |+| Q67 | 2026-09-05 | Req 5.2's visual knock-down is verified by the owner's device check at task 20 only; the journeys assert the Req 5.5 words that stand in for it | XCUITest cannot read opacity, and no fixture work is both abandoned and typed (Q58), so the abandoned glyph is never observed after a type tag either |+| Q68 | 2026-09-05 | Req 5.1's "no taller" is measured at accessibility5 as well as at default type, sorted A to Z so the two rows share one layout pass | At default type the always-present entry-count pill and the 44 pt hit target set the row height, so that comparison cannot discriminate; the XXXL row is content-driven |+| Q69 | 2026-09-05 | `WorkMergeOutcome`'s three target values are defaulted parameters, unlike `WorkVariantSide`'s (Q52) | The outcome only describes the target after the fold has run; an omission can show a default in a summary line that does not exist yet (Q51), never write or drop reader text, so it sits with `WorkEditBasis` (Q47) rather than with the two required carriers (Q40, Q52) |+| Q70 | 2026-09-05 | Every reader of a verdict uses `M2Unicode.isBlank` — the reconciler guard, the read-mode paragraph, the fold and the sheet — so a whitespace verdict reads as absent everywhere; the writer still trims in `updateWork` alone (Q33, Q55) | Import writes verbatim, so a blank-but-non-empty verdict is reachable; two readers on `isEmpty` would propagate it and render an empty block. Found by the pre-push review |++## Decision 1: Finished reading requires a finished work++**Date**: 2026-09-04+**Status**: accepted++### Context++A reader who has read every published chapter of an ongoing work is caught up, not finished. If the reading status allowed `finished` on an ongoing work, the value would mean two different things across the library — "read it all" and "read what exists" — and a later chapter would silently make the first meaning false.++### Decision++Reading status `finished` is only valid when the work status is `finished`. Selecting it on an ongoing or hiatus work presents a choice: mark the work finished as well, use `abandoned` instead, or cancel. The rule is applied in edit mode — on the picker transitions and again at commit whenever the draft pair differs from the stored pair (Q20). A stored combination that violates it (for example after independent edits on two devices) is displayed as stored, and an edit that leaves the pair untouched writes it back unchanged.++### Rationale++"Finished" should always mean the reader read the whole work. Offering to mark the work finished in the same dialog keeps the common case — the reader knows the work ended and is recording both at once — to one extra tap. Offering `abandoned` covers the reader who stopped on a running work.++### Alternatives Considered++- **A fourth reading value, `caught up`**: Represents "read everything published so far" explicitly - Rejected because it is a derived state (newest capture equals newest chapter) that the app cannot verify and the reader would have to maintain by hand; `reading` already covers it.+- **No rule; any combination allowed**: Simplest to build - Rejected because it makes `finished` ambiguous and the filter for finished reading unreliable.+- **Enforce the invariant in storage (reject the write)**: Rejected because two devices can each make a valid edit that together violate it; a stored violation must be tolerated and repaired, not refused.++### Consequences++**Positive:**+- `finished` reading has one meaning everywhere it is shown or filtered.+- The dialog records the work's own status at the moment the reader most likely knows it.++**Negative:**+- One more dialog in the edit flow for the reader who picks `finished` on a work they forgot to mark finished.+- The invariant is a UI rule, not a data guarantee; tests and readers must expect stored violations to exist and display.++---++## Decision 2: The finished-reading dialog keeps the confirmation-dialog presentation++**Date**: 2026-09-05+**Status**: proposed++### Context++Req 3.1 says the finished-reading dialog offers exactly three actions and Req 10.2 says it presents all three at the largest accessibility sizes. `WorkDetailView.finishedReadingDialog` declares three buttons — mark the work finished, use abandoned, cancel — on the same `confirmationDialog` with a `presenting:` value that the four existing detail dialogs use (Q37). Measured in the accessibility tree during task 18, iOS 26 presents this dialog on the phone as an anchored popover at every type size, and a popover-presented confirmation dialog omits the declared cancel button: the dismiss region is the platform's cancel, and `work-detail-finished-cancel` exists on no iPhone presentation. The same fact is recorded for the delete dialog in `pending-capture-queue` Q53, whose requirements never counted the buttons.++### Decision++Keep the `confirmationDialog` presentation with the declared Cancel button. The journeys cancel through `declineConfirmationDialog` and assert that the drafts are unchanged afterwards (Q66). `requirements.md` is left as written until the owner either amends Req 3.1 and 10.2 to "three outcomes, the third being the platform's dismissal" or asks for a presentation that draws Cancel.++### Rationale++The declared Cancel is not dead everywhere: macOS and regular-width presentations may draw it, and the delete dialog carries the same declaration for the same reason. The rule the requirement protects — a way out that changes nothing — holds on every presentation, and the journeys prove it. Amending a requirement mid-feature is the owner's call, not the implementer's, so the wording stays and this entry is the pointer a reader hunting for the third button needs.++### Alternatives Considered++- **`.alert` with three buttons**: draws all three on every platform — rejected because it changes the register of a three-way choice and splits the pattern all five detail dialogs share, leaving the delete dialog inconsistent for the same platform behaviour.+- **A custom sheet**: full control over the buttons — rejected as a new presentation recipe for one dialog, against the style guide's one-implementation rule.+- **Amend the requirements now**: rejected because the wording is the owner's; the entry stays `proposed` until they rule.++### Consequences++**Positive:**+- One dialog shape across the detail screen, and no new recipe.+- The cancel path is asserted by behaviour (drafts intact) rather than by a button's existence.++**Negative:**+- Req 3.1 and 10.2 read as unmet until amended; no UI test can pin "exactly three actions".+- The declared Cancel is unreachable on the phone, which a reader of the view code will have to learn from this entry.++---
diff --git a/specs/work-and-reading-status/design.md b/specs/work-and-reading-status/design.mdnew file mode 100644index 0000000..ab023c8--- /dev/null+++ b/specs/work-and-reading-status/design.md@@ -0,0 +1,210 @@+# Design: Work and Reading Status++## Overview++Two reader-entered statuses and a verdict become three stored columns on `Work` under schema V10, flow through the same authored-content chain as `genreTags`, and surface as segmented controls in edit mode, glyph items on the meta line and the Works row, an abandoned-last partition of the list, and two closed-vocabulary filter dimensions. The backup archive moves to format 9 over schema 10.++## Architecture++### Enums and columns++`DomainEnums.swift` gains two enums shaped exactly like `TitleProvenance`:++```swift+public enum WorkStatus: String, CaseIterable, Codable, Sendable { case ongoing, finished, hiatus }+public enum ReadingStatus: String, CaseIterable, Codable, Sendable { case reading, finished, abandoned }+```++`Work` (`Models.swift`, inside `extension AsterismSchemaV10`) gains three stored columns with literal defaults and two tolerant accessors on the `titleProvenance` pattern:++```swift+public var workStatusRaw: String = WorkStatus.ongoing.rawValue+public var readingStatusRaw: String = ReadingStatus.reading.rawValue+public var verdict: String = ""++public var workStatus: WorkStatus { get { ToleratedEnum.read(workStatusRaw, default: .ongoing) } set { workStatusRaw = newValue.rawValue } }+public var readingStatus: ReadingStatus { /* same, default .reading */ }+```++Defaulted, non-optional, non-unique: the CloudKit-mirrored shape every other column already has. The raw spellings are frozen once the V10 snapshot exists; the accessor tolerates an unknown spelling, an empty string included, by reading the default ([1.3](requirements.md#1.3), [2.7](requirements.md#2.7)), while export refuses the same value by name ([8.3](requirements.md#8.3)). `Work.init` and both `create` doors are untouched because a defaulted column is not an init parameter ([2.6](requirements.md#2.6)).++The property initialiser is what SwiftData turns into the Core Data attribute default, and that default is what fills the three columns on every existing row during the lightweight stage ([9.1](requirements.md#9.1)). V10 is the first version in this project to add a non-optional scalar to an existing table under a bare lightweight stage (every earlier added column was optional or came with a data pass), so `V9RecordedStoreTests` asserts the **raw columns** hold the default spellings after conversion; reading through the accessor would pass even if the default never landed.++`ReadingStatus.isDone` (`finished || abandoned`) is the one derived predicate; everything that shows or hides the verdict reads it.++### Schema V10 and bootstrap++Follows the six-row table in `docs/agent-notes/schema-migration.md`:++| Step | This feature |+|---|---|+| Freeze + declare | `Models.swift`'s ten classes copied into `AsterismSchemaV9.swift` as the frozen snapshot (stored columns and `@Relationship` macros only, `public init() {}`), `AsterismSchemaV8.swift` and `V8RecordedStoreFixture` deleted (Q18). New `AsterismSchemaV10.swift` declares the same ten models at `Schema.Version(10, 0, 0)`; `Models.swift` opens `extension AsterismSchemaV10` and every top-level typealias repoints |+| Stage | `AsterismV10MigrationPlan` = `[V9, V10]`, one `.lightweight(fromVersion: V9, toVersion: V10)`. Purely additive; no data pass. The floor rises to V9: a `4.0.0` store still reads below the floor for the classifier suites, and every fixture that seeded through the V8 snapshot is rewritten over the frozen V9 |+| Markers | `laggingOpenableMarkerVersion = "9"`, `extensionOpenableMarkerVersion = "10"`; `appOpenableMarkerVersions` stays a two-element set, `["9", "10"]`. `publishReadiness` is unversioned and needs no edit. Marker `"8"` joins the retired digits ([9.2](requirements.md#9.2)) |+| BootstrapState | No new case: `.markerLagging(generation:)` and the classifier rows are constant-driven. Comments naming `"8"`/`"9"` are rewritten; `abbreviatedMarkerText`'s one-character remark goes |+| Upgrade path | The existing `.markerLagging` arm unchanged in shape: open (the stage adds the columns inside `ModelContainer.init`) → `validateStore` → `publishReadiness` (`"10"`) → `clearResidualEvidence`. Validation is the gate; a throw leaves `"9"` on disk and the next open re-enters over an already-converted store, which is a no-op ([9.1](requirements.md#9.1)) |+| Extension | Opens only `"10"`; the refusal fork is unchanged, `"9"` gets "Open Asterism to finish updating the library" ([9.3](requirements.md#9.3)) |++Two audits ride with the freeze: both `Schema(versionedSchema:` sites in Sources (`LibraryRepository+Bootstrap.swift:414` and the `LibraryRepository+BackupImportGates.swift:43` scratch container, the one the migration note warns about) name `AsterismSchemaV10`, and every test-side `Schema(versionedSchema: AsterismSchemaV9.self)` (33 sites) moves to V10 except the new recorded-store fixture, which seeds through the frozen V9 on purpose.++The recorded-store fixture is the one place V10 is harder than V9: V10 *adds*, so the live shape is no longer a subset of the frozen one (the relation the migration note relies on for a stale registration to be harmless) and only the create-seed-save-release ordering in `write(at:)` keeps the registry coherent. `V9RecordedStoreFixture` copies `V8RecordedStoreFixture`'s ordering and says so in its doc comment; its `Work` rows carry no status values, so `V9RecordedStoreTests` can assert that the stage supplied all three defaults and that the marker moved `"9"` → `"10"`. `make test-core`'s `--no-parallel` stays load-bearing.++The literal `"10"` is today the canonical *unrecognised* marker text in `BootstrapClassifierTests` (including its `.unrecognisedText` axis case), `BootstrapActionTests`, `MarkerContractTests` and `BootstrapStateCoverageTests`. Those move to `"99"` (Q28) before the constant changes, so the suites keep testing an unknown digit rather than the live one. The `"8"` and `"9"` literals themselves sit in a dozen test files (the marker, certification, lifecycle and recorded-store suites, `FrozenLibraryPathTests` and `V8RecordedStoreTests`), each of which moves one generation.++`specs/retire-migration-chain/library-graph-baseline.txt` moves to `format 7`: `LibraryGraphBaselineTests` serialises the three new columns after `titleProvenanceRaw`, and the baseline is re-recorded and its diff reviewed, per the file's own header.++### Choke-point parity audit++Every surface `genreTags` flows through, and what the three new fields do there. "Carrier" is the group's representative row as `GroupOrdering` picks it.++| Site | Change |+|---|---|+| `WorkSnapshot` (`Snapshots.swift:101`) | `workStatus`, `readingStatus`, `verdict`, all defaulted in the init so the three production builders are the only edits |+| `LibraryRepository.snapshot(_:types:)` (:1747) | reads the accessors off the row |+| `LibraryRepository+Groups.snapshot(_ group:)` (:391, :403) | both arms forward; the split arm forwards the **carrier's** values like `genreTags` |+| `WorkAuthoredContent` (`GroupOrdering.swift:227`) | three defaulted fields; `isBare` adds `workStatus == .ongoing && readingStatus == .reading && verdict.isEmpty`; `orderComponents` appends `.string(workStatus.rawValue)`, `.string(readingStatus.rawValue)`, `.string(verdict)` ([7.2](requirements.md#7.2)) |+| `authoredContent(of:primary:typeAssignment:)` (:537) | reads the three off the row — the single producer |+| `DuplicateReconciler.apply(_:to:)` (:1222) | three arms on the exact `genreTags` shape at :1278: write only when the carrier's value is **non-default** (non-empty for the verdict) *and* differs from the row's. A carrier on `reading` must never overwrite a sibling's `abandoned`. No `propagates` gate; that gate exists only because a type may be `.removed` |+| `WorkMetadataDraft` (`RepositoryDrafts.swift:44`) | three **required** parameters (Q40): `updateWork` writes all three to every row, so a draft built without them would silently reset a reader's statuses; the fourteen test sites and the two fixture seeds in `AppLibraryModel` pass them explicitly |+| `WorkEditBasis` (`LibraryWrites.swift:207`) and `init(work:)` | three fields, forwarded from the snapshot ([7.4](requirements.md#7.4)) |+| `WorkEditBasis.matches` (`+Redirect.swift:317`) | three plain `==` comparisons |+| `updateWork` (`LibraryRepository.swift:1205`) | inside the every-row loop: `workStatus`, `readingStatus`, `verdict = normalizeVerdict(draft.verdict)` (trim only, Q33) ([7.1](requirements.md#7.1)) |+| `DuplicateResolutionField` (`DuplicateResolution.swift:15`) | `.workStatus`, `.readingStatus`, `.verdict` |+| `WorkVariantChoice` (:64) and `choice(_:rows:types:)` (`+DuplicateResolution.swift:334`) | three fields, surviving row's value with the variant content as fallback, as `genreTags` does |+| `differingWorkFields` (:407) | `Set(contents.map(\.workStatus)).count > 1` and the same for the other two; no sentinel, the values are non-optional |+| Survivor write loop (:647) | carrier's three values written to every surviving row, beside the title and type (carrier-wins, [7.2](requirements.md#7.2)) |+| `WorkVariantSide` (`WorkVariantUnion.swift:15`) and both convenience inits | three fields |+| `WorkVariantUnion.fold` (:121) | see the merge section |+| `WorkMergeField` (`ProjectionContract.swift:671`), `WorkMergeOutcome` (:736, three new init parameters defaulted like `movedCharacterCount`), `WorkMergePlanner` (:91, :195) | see the merge section |+| `LibraryRepository+WorkMerge` target loop (:403) | unchanged: the target keeps its own three, exactly as it keeps its type |+| `LibraryRepository+ConfirmImport.apply(_:to:)` (:777) | three assignments |+| `BackupArchiveProjection` (`mapV9WorkRecord`, `requireRepresentableValues` :227) | three fields on the wire, the two statuses as the typed enums like `titleProvenance`; two `require(WorkStatus(rawValue:)…)` / `ReadingStatus` lines beside `titleProvenance`'s ([8.3](requirements.md#8.3)) |+| `LibraryValidator.validate(work:)` | unchanged: the arm validates nothing authored, and an unknown raw value is data, not damage |+| Markdown export | unchanged (non-goal) |+| `DuplicateResolutionView.workVariantContent` (:243) | three `if model.differingFields.contains(…)` arms; the verdict arm shows the text whatever the reading status, the exception [2.5](requirements.md#2.5) grants (Q21) |+| `WorkDetailModel` drafts, `load`, `hasUnsavedChanges`, `restoreDraftsFromSnapshot`, `save` | see the work detail section |++`VariantID` hashes `orderComponents`, so every existing Work variant id changes once (Q29). The ids live in the in-memory settling ledger and in `WriteConflict.torn` disclosures; a disclosure taken before the update reads as `disclosureStale` after it and the sheet re-presents. One re-derivation pass, no migration.++### Merge++The target keeps its statuses and verdict; nothing in the target loop writes them (Q13). `WorkVariantUnion.fold` is shared by the merge planner and the duplicate-resolution survivor write, so what it does with a losing side's verdict happens on both paths, as it already does with a losing side's notes (Q41):++- seeds `retained` with `.targetWorkStatus`, `.targetReadingStatus` and `.targetVerdict` unconditionally, as `.targetNotes` and `.targetType` are seeded;+- per side, appends `.sourceWorkStatus` / `.sourceReadingStatus` to `discarded` when the side's value is **non-default** and differs from the target's (a default contributes nothing, Q14);+- per side with a non-empty verdict that differs from the target's, passes it to `WorkMergeAuditFormatter.block(sourceTitle:discardedWorkURLs:sourceVerdict:sourceNotes:)`, which writes a `Verdict:` line with the structured lines, before the free-form notes; sets `verdictRecorded`, which joins the block's existing `titleDiscarded || urlDiscarded || notesRetained` gate so a side differing only by verdict still produces a block; and appends `.sourceVerdict` to `discarded`.++In duplicate resolution the survivor write already copies `union.genericNotes`, so a losing variant's verdict lands in the survivors' notes under the same block as its losing notes; the retained and discarded lists are computed there and rendered nowhere, as today.++`WorkMergeField` gains a `recordedInNotes: Bool`: true for `.sourceManualTitle`, `.sourceWorkURL`, `.sourceNotes`, `.sourceVerdict`; false for the two source statuses and the existing `.sourceGenreTags`, which are unioned, not recorded. `WorkMergeView`'s discarded section reads it and captions each row "Recorded in merged notes" or "Not carried over", its row `accessibilityLabel` at :227 and the audit-block label at :247 ("discarded values will be recorded in target notes") reworded to match ([7.3](requirements.md#7.3)); `fieldLabel` gains the six labels. `WorkMergeOutcome` carries the target's three values, mirroring `typeDisplay`, for the preview's summary line.++### Works list++**Ordering.** `WorksSort.apply(to:)` ends with a stable partition over its existing result, spelled as two `filter` calls concatenated (`readingStatus != .abandoned` first); `partition(by:)` is unstable and is not used. `WorksView` then splits by emptiness with `filter` as today, and because both steps preserve relative order, every section ends with its abandoned works in the active sort's order, under `.oldest`'s per-section reversal included ([5.3](requirements.md#5.3)). The merge picker does not go through `WorksSort` and keeps its order ([5.4](requirements.md#5.4)).++**Row.** `WorkRow` (shared with the merge picker, Q22) gains, after the type tag and before the `Spacer`, up to two glyphs from `WorkStatusPresentation`: the work glyph when `workStatus != .ongoing`, the reading glyph when `readingStatus.isDone`. Each is an `Image(systemName:)` in the pill's own text font (`.caption` semibold), so the symbol's height sits inside the pill's padded height ([5.1](requirements.md#5.1)); identifiers `work-status-glyph` / `reading-status-glyph`. An abandoned row applies `.opacity(ConstellationRecipes.knockdownOpacity)` to the whole `VStack` and `AsterismColors.secondaryText` to the title ([5.2](requirements.md#5.2)). The row is one button, so VoiceOver hears nothing from child labels: `WorksRowPresentation.openLabel(for:)` and `WorkMergeView.destinationLabel` both append the status clauses after the site, and the `work-title` `Text` carries the same clause in its own `accessibilityLabel` ([5.5](requirements.md#5.5)). The clauses are joined with `". "` rather than `", "`, because the label already lists several hostnames comma-separated (Q32); `listedTitles()` reads the substring before `" from "` and needs no change.++**Filters.** `WorksFilter` gains `workStatus: WorkStatus?` and `readingStatus: ReadingStatus?` as stored properties and as two nil-defaulted parameters on its explicit `init`; `isActive`, `apply`, `matches` gain the two clauses. `pruned(to:)` leaves both alone: the vocabularies are closed, so `WorksFilterOptions` carries nothing for them and the menu iterates `allCases` ([6.3](requirements.md#6.3), Q30). `WorksView.optionsMenu` adds two `filterPicker` calls under `Site`, titled "Work status" and "Reading status", rows labelled by `WorkStatusPresentation.name` with identifiers `works-filter-work-status-{raw}` / `works-filter-reading-status-{raw}` and `works-filter-work-status-any` / `works-filter-reading-status-any`, the existing `works-filter-type-any` pattern. `WorksFilterPresentation.activeLabels` and `emptyDescription` emit the qualified form, "Work: Finished" ([6.2](requirements.md#6.2), Q23). Both statics stay `nonisolated`.++### Work detail++**Meta line** (`WorkDetailView.metaLine`, a `FlowLayout`): two more children after `▼`, each a `Label(name, systemImage:)` in the glyph's hue with identifiers `work-detail-status-work` and `work-detail-status-reading`, present only when the status is off its default ([4.1](requirements.md#4.1)). The `ViewThatFits` drop-under and `.lineLimit(1)` on the flow already apply.++**Verdict paragraph**: after the tag row and before the notes, gated on `readingStatus.isDone && !verdict.isEmpty`: a `.caption` `secondaryText` "Verdict" line and the text in the notes recipe (`.subheadline`, `noteText`, `lineSpacing(4)`), container `work-detail-verdict` ([4.2](requirements.md#4.2)).++**Edit mode** (`editHeaderSection`), card order: Title, Type, Work status, Reading status, Verdict (when `draftReadingStatus.isDone`), Tags. Each status card carries a visible caption above its control, "Work status" / "Reading status", in `.caption` semibold `secondaryText`, because both capsules contain a segment called "Finished" and nothing else on screen says which is which; the control is `ConstellationSegmentedControl` (Q26) with the same text as its `containerLabel`, per-segment titles from `WorkStatusPresentation.name` and identifiers `work-detail-work-status-{raw}` / `work-detail-reading-status-{raw}`; at the accessibility sizes the control already stacks full width. The verdict card uses the same caption recipe for its prompt (`ReadingStatusPresentation.verdictPrompt`) above `TextField("Verdict", text: $model.draftVerdict, axis: .vertical).lineLimit(3...6)`, identifier `work-detail-verdict-field` ([2.3](requirements.md#2.3)).++**Model.** `WorkDetailModel` gains `draftWorkStatus`, `draftReadingStatus`, `draftVerdict`, seeded in `load()`, compared in `hasUnsavedChanges`, restored in `restoreDraftsFromSnapshot()`, and written in `save(reloading:)` through the draft and the basis (the nil-snapshot fallback basis lists them too). `draftVerdict` is always written, whatever the reading status: a verdict typed and then hidden by a status change commits and stays stored, which is what [2.5](requirements.md#2.5) asks and what keeps a [3.3](requirements.md#3.3) auto-revert from erasing stored text. The two controls bind through `setDraftWorkStatus(_:)` and `setDraftReadingStatus(_:)`, not raw property writes, because the transitions carry rules:++| Transition | Effect |+|---|---|+| any reading status → `.finished` while `draftWorkStatus != .finished` | `finishedReadingPrompt = FinishedReadingPrompt(thenCommits: false)`; the draft is not changed, so the capsule keeps showing the previous value while the dialog is up ([3.1](requirements.md#3.1)). Re-selecting `.finished` when the draft is already `.finished` is a no-op, so a stored violating pair does not raise the prompt by being tapped |+| work `.finished` → other while `draftReadingStatus == .finished` | `draftReadingStatus = .reading` and `autoRevertedReading = true`; the verdict card disappears, `draftVerdict` is kept ([3.3](requirements.md#3.3), [2.5](requirements.md#2.5)) |+| work other → `.finished` while `autoRevertedReading` and `draftReadingStatus == .reading` | `draftReadingStatus = .finished`, flag cleared: the mis-tap Q7 protects is undone in full (Q39). Any other reading change clears the flag |+| `commitEditing()` with `draftReadingStatus == .finished`, `draftWorkStatus != .finished`, and the draft pair ≠ the snapshot's pair | `finishedReadingPrompt = FinishedReadingPrompt(thenCommits: true)`; returns before any write ([3.4](requirements.md#3.4)) |+| `resolveFinishedReadingPrompt(prompt, choosing: .markWorkFinished)` | `draftWorkStatus = .finished`, `draftReadingStatus = .finished`; if `prompt.thenCommits`, awaits `commitEditing()` again, which now passes the check |+| `resolveFinishedReadingPrompt(prompt, choosing: .abandonInstead)` | `draftReadingStatus = .abandoned`; same commit follow-through |+| `cancelFinishedReadingPrompt()` | clears the prompt; the drafts were never changed, so nothing is restored; edit mode stays, nothing written |++The resolver is **two-parameter and takes the presented prompt** — `resolveFinishedReadingPrompt(_ prompt: FinishedReadingPrompt, choosing resolution: FinishedReadingResolution) async` — and **cancel is not one of its cases** (Q62). Both follow from Q37's own reason: SwiftUI clears the presented value before the tapped button's action runs, so `thenCommits` has to travel on the prompt rather than be read back off the model, exactly as `chooseDeletion(_:disposition:)` / `cancelDelete()` are shaped. Cancel is therefore the *synchronous* `cancelFinishedReadingPrompt()`, which is also what the `isPresented` binding's setter calls, so the drawn Cancel button and the platform's own dismissal are one path; `FinishedReadingResolution` has two cases, `markWorkFinished` and `abandonInstead`.++`resolveFinishedReadingPrompt` is `async` because of the follow-through. When that second `commitEditing()` stops in the URL step, the resolved statuses stay in the drafts and edit mode stays open with the URL message showing, as any other URL refusal leaves the other drafts; nothing is written, and the reader's choice is visible in the capsules rather than silently dropped. `FinishedReadingPrompt` is `Identifiable, Equatable, Sendable` (`id` is the work's own UUID) with its `message` computed on the value, the `WorkDeletionPrompt` shape; the message is Q64's "Finished reading means you read the whole work, and this one is not marked finished." plus "Nothing has been saved yet." on the commit arm and "Choose what to record." on the picker arm. The dialog is a `.confirmationDialog("Mark as finished?", presenting: model.finishedReadingPrompt)` — in its own `finishedReadingDialog(_:)` modifier rather than a sixth presentation inline on the screen's `List`, which the type checker would not solve — beside the four existing ones, buttons "Mark the work finished too", "Abandoned instead", and `Cancel` (role `.cancel`), identifiers `work-detail-finished-mark-work` / `-abandon` / `-cancel`. The presented value travels as `presenting:`, the house rule, because the `isPresented` setter runs before the tapped button's action, and that setter's dismissal is the cancel path. The check runs first in `commitEditing()`, ahead of the URL step, so a cancel leaves every draft intact. A stored pair that violates the rule and is committed unchanged passes the pair-differs guard and writes back as-is ([3.5](requirements.md#3.5)).++**How the dialog actually presents on the phone (Q65).** iOS 26 draws this `confirmationDialog` as an **anchored popover** at every type size on iPhone, measured in the accessibility tree at 402×874 default type and at accessibility5 — the same fact `pending-capture-queue` Q53 records for the delete dialog. A popover omits the declared cancel, so **`work-detail-finished-cancel` exists on no iPhone presentation**: the third outcome is the platform's dismiss region, not a drawn button. The declared `Button("Cancel", role: .cancel)` stays, because macOS and regular-width presentations may draw it and the journeys cancel through `declineConfirmationDialog`, whose fallback tap now picks a point in a margin the popover leaves (Q66). The presentation stays `confirmationDialog` rather than `.alert`, which would draw the button but split the house pattern all five detail dialogs share. [3.1](requirements.md#3.1)'s "exactly three actions" and [10.2](requirements.md#10.2)'s "present all three" are left as the owner wrote them; this paragraph is where the landed fact is recorded.++**Presentation.** New `Asterism/Asterism/Views/WorkStatusPresentation.swift` beside `WorkTypePresentation.swift` (Q36): for each enum, `name`, `systemImage`, `hue` (work glyphs violet like the type tag, reading glyphs cyan like the count pill, Q27), `accessibilityLabel` ("Work: Finished", "Work: On hiatus", "Reading: Finished", "Reading: Abandoned"), and `ReadingStatusPresentation.verdictPrompt` ("Why did you stop?" / "How was it?"). Glyphs: `flag.checkered`, `pause.circle`, `checkmark`, `book.closed`; none is used elsewhere in the app. Every string the row label, filter pills and dialog use comes from here, so the UI tests and the views agree on one spelling.++No platform seam: `ConstellationSegmentedControl`, `Menu`, `Picker` and `confirmationDialog` are cross-platform, matching the policy `WorksView` states ([10.3](requirements.md#10.3)).++### Backup format 9/10++`BackupV8Types/Codec/Exporter.swift` become `BackupV9*` with every `BackupV8X` record renamed, `formatVersion = 9`, `schemaVersion = 10` (Q17, Q34, the `rule-citation-by-uuid` Q14 policy). `BackupV9Work` gains `workStatus: WorkStatus`, `readingStatus: ReadingStatus`, `verdict: String`, the enums typed on the wire exactly as `titleProvenance: TitleProvenance` is, all required: the only accepted archive is one this build wrote, and a malformed one fails on decode. `BackupImporter.supportedVersions` follows the constants; its refusal message already names both pairs ([8.2](requirements.md#8.2)). `BackupV8Fixtures`/`BackupV8ArchiveTests` rename with the types; `backup-8-9-golden.json` is deleted and `backup-9-10-golden.json` recorded through `ASTERISM_RECORD_GOLDEN=1`; `FrozenLibraryPathTests`' archive-name bucket, `docs/agent-notes/rule-wire-format.md` and the migration note's "Current state" list move with it. The gate literal stays `"multi-site"`.++### UI test fixture++`seedWorksOptionsFixture` gives its four works statuses through the final `updateWork` in `seedWorksOptionsWork` (the whole-work seeding that keeps date order deterministic): Marrow Lane abandoned with a verdict, Ashfall hiatus (it already wears the dimmed removed-type pill, so it must not also be the abandoned row), Zephyr Court finished/finished with a verdict, Quill Harbour on defaults. That one scenario then drives the ordering, dimming, glyph, filter, and detail journeys; the four expected-order constants in `WorksListOptionsUITests` are rewritten for Marrow Lane sinking.++### Documents updated in the same change++`docs/asterism-style-guide.md` §7 (meta line items, row glyphs, abandoned knockdown) and §8 (the four symbols); `docs/asterism-design.md` §5.2 and §6 (row marks, abandoned-last, verdict paragraph, edit-mode order); `docs/agent-notes/schema-migration.md` (current state at V10, the History list, and the recorded-store section's warning that an adding version loses the live-within-frozen subset relation, now lived rather than predicted); `specs/OVERVIEW.md`; `CHANGELOG.md`.++## Components and Interfaces++```swift+// AsterismCore+public struct WorkMetadataDraft {+ public init(displayTitle: String, typeAssignment: WorkTypeAssignment, genreTags: [String], genericNotes: String,+ workStatus: WorkStatus, readingStatus: ReadingStatus, verdict: String) // no defaults, Q40+}+public struct WorkEditBasis { /* + workStatus, readingStatus, verdict; init(work:) forwards them */ }+public struct WorkAuthoredContent { /* + the three, defaulted; isBare and orderComponents extended */ }+public enum DuplicateResolutionField { /* + workStatus, readingStatus, verdict */ }+public enum WorkMergeField { /* + targetWorkStatus, targetReadingStatus, targetVerdict, sourceWorkStatus, sourceReadingStatus, sourceVerdict */+ public var recordedInNotes: Bool }+enum WorkMergeAuditFormatter { static func block(sourceTitle:discardedWorkURLs:sourceVerdict:sourceNotes:) -> String? }++// App+struct WorksFilter { var workStatus: WorkStatus?; var readingStatus: ReadingStatus? /* + type, tag, hostname; init gains two nil-defaulted parameters */ }+enum WorkStatusPresentation { static func name(_:), systemImage(_:), hue(_:), accessibilityLabel(_:) }+enum ReadingStatusPresentation { /* same, + verdictPrompt(_:) */ }+struct FinishedReadingPrompt: Identifiable, Equatable, Sendable { let id: UUID; let thenCommits: Bool; var message: String }+enum FinishedReadingResolution { case markWorkFinished, abandonInstead } // no cancel case, Q62+// WorkDetailModel: setDraftWorkStatus(_:), setDraftReadingStatus(_:), finishedReadingPrompt,+// resolveFinishedReadingPrompt(_ prompt:choosing:) async, cancelFinishedReadingPrompt()+```++Contracts worth stating:++- `updateWork` writes the three to **every row** of the group inside the same lock as the other fields; a partial write would re-tear the group.+- `isBare` must compare all three against their defaults. If it does not, every work is non-bare, the `presentedContent.isBare` fast path in `redirectWork` never fires, and silent duplicate healing stops library-wide with no compiler error.+- `WorksSort.apply`'s partition is stable and runs last; the filter and search never reorder.+- `setDraftReadingStatus(.finished)` on a non-finished work changes nothing until the prompt resolves; the segmented control therefore shows the previous value while the dialog is up.++## Data Models++| Column | Type | Default | Read |+|---|---|---|---|+| `Work.workStatusRaw` | `String` | `"ongoing"` | `ToleratedEnum.read(_, default: .ongoing)` |+| `Work.readingStatusRaw` | `String` | `"reading"` | `ToleratedEnum.read(_, default: .reading)` |+| `Work.verdict` | `String` | `""` | as is; trimmed on write |++Archive `BackupV9Work`: the same three, raw strings and the verdict, required.++## Error Handling++- No new error kinds. The three fields follow the same conflict rule as the work's other edited fields: the basis is compared only when a write is redirected after a collapse, where a survivor differing on a status or verdict refuses with `WriteConflict.survivorDiverged`; a direct write to a row that still exists is last-write-wins, as it is for every field today ([7.4](requirements.md#7.4)).+- Export of a work carrying an unknown status raw value throws `BackupV9ExportError.unrepresentableValue` naming `Work <id>`, "work status" or "reading status", and the raw text, from `requireRepresentableValues` ([8.3](requirements.md#8.3)).+- Import of an 8/9 archive throws `BackupImportError.unsupportedFormat` with the existing wording, which names both pairs ([8.2](requirements.md#8.2)).+- A marker other than `"9"`/`"10"` is refused by the existing classifier row naming the marker ([9.2](requirements.md#9.2)); a pre-feature build meets its own unknown-marker refusal on a `"10"` store ([9.4](requirements.md#9.4)).++## Testing Strategy++- **Model and contract** (`make test-core`): `ModelContractTests` pins the V10 entity list, the three defaults on a fresh `Work`, and that `AsterismSchemaV9.swift` is the only frozen snapshot; the pinned file sets in `ModelContractTests` and `MultiSiteReadPathTests` name it; `FrozenLibraryPathTests` buckets move to the V9/V10 and `BackupV9*` names; `EnumTolerancePolicyTests` covers both new accessors; `V9RecordedStoreTests` seeds through the frozen V9, opens `openForApp`, and asserts the marker `"9"` → `"10"`, the recorded schema `9.0.0` → `10.0.0`, the three defaults on every migrated `Work`, and the extension's refusal of `"9"`; `V4RecordedStoreTests` unchanged; `MarkerGenerationTenTests` replaces the Nine suite for the certify-then-publish order and the failure that leaves `"9"` in place; `BootstrapClassifierTests`' marker axis gains `ten` and `eight` joins its retired list; the five suites using `"10"` as unknown text move to `"99"` first.+- **Creation defaults** ([2.6](requirements.md#2.6)): `RepositoryWorksTests` (New Work form and Move To a new work), `RepositoryCaptureTests` (a capture that mints a work, and one into a finished/abandoned work leaving all three untouched), and `BackupImportTransactionTests` (a materialised work) each assert the three defaults.+- **Authored content and writes**: `GroupOrderingTests` — `isBare` is true only at all three defaults, a differing status or verdict forms a variant, `orderComponents` change with each field; `FanOutWriteTests` / `RepositoryWorksTests` — `updateWork` lands the three on every row and trims the verdict; `PostCollapseRedirectTests` — a survivor differing on status refuses with `survivorDiverged`; `DuplicateReconcilerTests` — a carrier's non-default status propagates to a sibling, and a carrier on the defaults never overwrites a sibling's `abandoned` or verdict; `DuplicateResolutionTests` — `differingWorkFields` names each new field, resolution writes the chosen variant's three to every survivor, the hidden verdict appears in the choice, and a losing variant's verdict lands in the survivors' notes under the audit block.+- **Merge**: `WorkMergePlannerTests` / `WorkMergeRepositoryTests` — target keeps its three; a non-default differing source status lands in `discarded` with `recordedInNotes == false`; a source verdict is appended to the audit block and listed with `recordedInNotes == true`, including a side that differs by verdict alone; identical or default values produce no entry.+- **Archive**: `BackupV9ArchiveTests` round-trip equality for the three fields, `(8, 9)` rejected naming both pairs and `(9, 10)` accepted (the pair matrix lives in that suite); `BackupExportDegradedRefusalTests` — an unknown or empty `workStatusRaw` refuses export by name; the golden re-recorded once and then byte-pinned.+- **Baseline**: `library-graph-baseline.txt` at `format 7`, diff reviewed.+- **App unit tests** (`make test-quick`): `WorksListOptionsTests` — abandoned-last under every sort, composed with `.oldest`'s own per-section reversal, stable among the abandoned works, both filters AND with the rest, `pruned` leaves a status alone; `WorksFilterPresentationTests` — qualified pill and empty-state labels; `WorksRowPresentationTests` — the label clauses and their `". "` separator beside a two-hostname label; `WorkDetailModelTests` — every row of the transition table including the re-tap no-op and the auto-revert restore, `hasUnsavedChanges` on a status-only or verdict-only change and on a verdict typed then hidden, cancel restores all three, the commit-time prompt fires only when the pair differs from the snapshot, `thenCommits` follows through and a URL refusal on the follow-through leaves the resolved drafts in place; `WorkStatusPresentationTests` — names, symbols and labels per case.+- **UI journeys** (`make test-ui`): `WorksListOptionsUITests` gains the abandoned-last order under each sort, the two filters, the pill text, and the dimmed row; `WorkMergeUITests` (or the existing merge journey) reads a destination row's status clause; a `WorkDetailStatusUITests` journey edits Marrow Lane to reading finished, meets the dialog, marks the work finished, saves, and reads both meta-line items and the verdict; `AccessibilityJourneyUITests` extends the XXXL works-options case to a status filter and adds the edit-mode controls, the dialog's three buttons, and a row wearing both glyphs ([10.2](requirements.md#10.2)); `WideLayoutUITests` reads the meta-line items on iPad (`make test-ui-ipad`).+- **Performance**: no new budget; the M4 suites must stay in their current bands, and the no-op reconcile's ceiling is unaffected because the reconciler's heal is gated before any authored comparison.
diff --git a/specs/work-and-reading-status/prerequisites.md b/specs/work-and-reading-status/prerequisites.mdnew file mode 100644index 0000000..f2cfe19--- /dev/null+++ b/specs/work-and-reading-status/prerequisites.md@@ -0,0 +1,21 @@+# Prerequisites for Work and Reading Status++These steps are the owner's. Every device install here is gated by `CLAUDE.md`'s device-run rule: approval at the moment of running, every time. The share extension refuses the library between the app update and the first app launch (Req 9.3), so update and open the app before sharing.++## Before the first V10 install++- [ ] Export an 8/9 archive from both apps (`Personal`, `Development`) on every device. The V10 build refuses an 8/9 archive (Req 8.2, Q17) and the V9 build refuses a `"10"` store (Req 9.4), so **rolling back means deleting the app, installing the V9 build, and importing this archive** into the fresh library it creates. Taken before the install, this archive is the only rollback.+- [ ] Keep a V9 build installable (the pre-feature commit).+- [ ] Confirm each device's library marker reads `"9"` (Q18 recorded that every device was at `"9"` on 2026-09-04; the V10 plan has no stage for an `"8"` store and refuses it naming the digit).++## During implementation++- [ ] After task 3 lands (schema V10), run the `Development` configuration once on a signed-in simulator or device so `NSPersistentCloudKitContainer` publishes the three new `Work` fields to the dev CloudKit container, as `character-extraction` did for its fields. Needed before any second dev device syncs.++## Install++- [ ] Approved device check: install `Personal` over the real library on one phone, confirm the first open completes the `"9"` → `"10"` conversion and every work reads ongoing / reading with no verdict, then update the second device **before either device opens the library again**. Adding columns is additive in CloudKit, but a V9 device syncing against V10 rows is not verified (requirements Non-Goals, Q15).++## Before release++- [x] `make test-performance-m4` (host-only, safe, ~21 min) run once by task 20 — done 2026-09-05, `verification-run.md` §4 (eight known issues, no ceiling left) — and its numbers recorded in `implementation.md`, with any drift in the accepted known-issue bands stated.
diff --git a/specs/work-and-reading-status/requirements.md b/specs/work-and-reading-status/requirements.mdnew file mode 100644index 0000000..bfe276c--- /dev/null+++ b/specs/work-and-reading-status/requirements.md@@ -0,0 +1,145 @@+# Requirements: Work and Reading Status++## Introduction++A work in the library carries no record of whether its author is still publishing it or of where the reader stands with it. This feature adds two reader-entered statuses to every work — the work's own status (ongoing, finished, hiatus) and the reader's status (reading, finished, abandoned) — with an optional verdict text once the reader is done. Both statuses show on the work detail screen and in the Works list, abandoned works step back visually and sort last, and both statuses become filters. Transit ticket T-2306.++## Non-Goals++- No automatic status: nothing is read from a work's site page, and a new capture never changes either status.+- No "abandoned" work status; a reader cannot usually tell a finished work from an abandoned one, and hiatus covers the temporary case.+- No status in the Stats page, the markdown export, or the share sheet.+- No status-driven changes to the Recent tab, and no change to the merge picker's order.+- No reader-selectable sort by status; the abandoned-last partition of [5.3](#5.3) is not a sort option.+- No status history, dates, or per-site status — one pair of values per work.+- No multi-select within a status filter; the filters follow the existing one-value-per-dimension rule.+- No status controls on the New Work form or the Move To chooser; a work created there takes the default statuses.+- No import of an archive written by a pre-feature build; the archive policy of one supported version pair per build stands (`specs/rule-citation-by-uuid/` Q8), and a fresh export after updating is the restorable one.+- No support commitment for a device still on a pre-feature build syncing against a converted library; the added fields are additive in CloudKit, but that is not verified.+- No migration path for a library still on readiness marker `8`; every device has passed it (decision log Q18).++## Definitions++- **Work status**: the author's side — one of `ongoing`, `finished`, `hiatus`. Default `ongoing`.+- **Reading status**: the reader's side — one of `reading`, `finished`, `abandoned`. Default `reading`.+- **Done reading**: a reading status of `finished` or `abandoned`.+- **Verdict**: an optional free-text field that accompanies a done-reading status. Under `abandoned` it records why the reader stopped; under `finished` it records how they found the work.+- **Default statuses**: work status `ongoing` with reading status `reading` — what every work carries until the reader changes it.+- **Abandoned work**: a work whose reading status is `abandoned` (the work status is irrelevant).+- **Edit mode**: the work detail screen's inline editing state, entered from its Edit control and committed by the navigation bar's confirmation (`specs/polish-and-export/`). There is no separate edit sheet.+- **Stored pair**: the work status and reading status as stored when edit mode was entered.+- **Pre-feature build**: the app version shipping immediately before this feature.+- **Group**: the set of stored rows that present as one work, as defined in `specs/multi-site-works/` and `specs/duplicate-reconciliation/`.+- **Review surfaces**: the duplicate review card (`specs/duplicate-reconciliation/`) and the merge preview (`specs/multi-site-works/`).++## Requirements++### 1. Work Status++**User Story:** As a reader, I want to record whether a work is still being written, so that I know at a glance which works will bring new chapters.++**Acceptance Criteria:**++1. <a name="1.1"></a>Every work SHALL carry a work status of `ongoing`, `finished` or `hiatus`, and a work that has never been given one SHALL read as `ongoing`. +2. <a name="1.2"></a>Edit mode SHALL offer a work status picker directly after the existing type picker, and committing edit mode SHALL persist the chosen value with the rest of the work's metadata. +3. <a name="1.3"></a>IF a stored work status value is one this build does not know, THEN it SHALL read as `ongoing` everywhere the status is shown or filtered, and committing edit mode SHALL write the value the picker shows. ++### 2. Reading Status and Verdict++**User Story:** As a reader, I want to record where I stand with a work and, once I am done, what I made of it, so that my library reflects what I actually read and why.++**Acceptance Criteria:**++1. <a name="2.1"></a>Every work SHALL carry a reading status of `reading`, `finished` or `abandoned`, and a work that has never been given one SHALL read as `reading`. +2. <a name="2.2"></a>Edit mode SHALL offer a reading status picker directly after the work status picker, persisted as in [1.2](#1.2). +3. <a name="2.3"></a>WHILE the draft reading status is done reading, edit mode SHALL show a multi-line verdict field directly after the reading status picker and before the tags field, under a label reading "Why did you stop?" for `abandoned` and "How was it?" for `finished`; WHILE the draft reading status is `reading`, the field and its label SHALL be absent. +4. <a name="2.4"></a>The verdict SHALL be stored trimmed of leading and trailing whitespace, with no app-imposed length limit, and an empty verdict SHALL be valid for either done-reading status. +5. <a name="2.5"></a>WHEN the reading status returns to `reading`, the stored verdict SHALL be kept and SHALL NOT be shown on the detail screen, in edit mode, or in the Works list until the status is done reading again; the review surfaces MAY show it where two rows differ on it. +6. <a name="2.6"></a>A work created by any path — the New Work form, Move To a new work, a capture, or a backup import of a record without statuses — SHALL carry the default statuses and an empty verdict, and capturing a new entry into an existing work SHALL leave both statuses and the verdict exactly as they were. +7. <a name="2.7"></a>IF a stored reading status value is one this build does not know, THEN it SHALL read as `reading` everywhere the status is shown or filtered, and committing edit mode SHALL write the value the picker shows. ++### 3. Finished Reading Requires a Finished Work++**User Story:** As a reader, I want the app to stop me from marking an unfinished work as finished, so that "finished" always means I read the whole thing.++**Acceptance Criteria:**++1. <a name="3.1"></a>WHEN the reader selects reading status `finished` while the draft work status is `ongoing` or `hiatus`, the system SHALL present a confirmation dialog offering exactly three actions: mark the work finished as well, use `abandoned` instead, or cancel. +2. <a name="3.2"></a>Choosing "mark the work finished as well" SHALL set the draft work status to `finished` and the draft reading status to `finished`; choosing "use abandoned instead" SHALL set the draft reading status to `abandoned` and leave the work status alone; cancelling SHALL restore the reading status the picker showed before the selection. +3. <a name="3.3"></a>WHEN the reader changes the draft work status from `finished` to `ongoing` or `hiatus` while the draft reading status is `finished`, the system SHALL set the draft reading status to `reading` and remove the verdict field per [2.3](#2.3), without a dialog. +4. <a name="3.4"></a>WHEN edit mode is committed with a draft reading status of `finished` and a draft work status other than `finished`, and the draft pair differs from the stored pair, the system SHALL present the dialog of [3.1](#3.1) before writing; the first two actions SHALL apply their change and then commit, and cancel SHALL return to edit mode with nothing written. +5. <a name="3.5"></a>IF a stored work carries reading status `finished` with a work status other than `finished` (for example after two devices edited it independently), THEN both stored values SHALL be shown as they are, and committing edit mode with the pair unchanged SHALL write it back unchanged. ++### 4. Work Detail Display++**User Story:** As a reader on a work's page, I want to see both statuses and my verdict, so that the page tells me where the work and I stand.++**Acceptance Criteria:**++1. <a name="4.1"></a>The work detail meta line (`{n} notes ▲ ▼`, `specs/work-detail-reading-redesign/`) SHALL show the work status and then the reading status as two further items, each a glyph with its status name, wrapping rather than truncating as the existing items do; a status at its default SHALL add no item. +2. <a name="4.2"></a>WHILE the reading status is done reading and the verdict is non-empty, the detail screen SHALL show the verdict as a paragraph between the tag pills and the work notes, under a label reading `Verdict`. +3. <a name="4.3"></a>The glyphs SHALL be distinct per value: one for a finished work, one for a work on hiatus, one for finished reading, one for abandoned reading; `ongoing` and `reading` have no glyph. ++### 5. Works List Display and Ordering++**User Story:** As a reader scanning the Works list, I want abandoned works to step back and works that will bring no new chapters to be marked, so that the list reads as my active library.++**Acceptance Criteria:**++1. <a name="5.1"></a>A work row SHALL show the finished-work or hiatus glyph from [4.3](#4.3) after the type tag when the work status is `finished` or `hiatus`, and the finished-reading glyph after it when the reading status is `finished`; the glyphs SHALL be no taller than the type tag. +2. <a name="5.2"></a>An abandoned work's row SHALL render its title, site glyph, type tag and count at reduced emphasis (the `dim` text colour and the opacity the style guide gives an ignored teach chip), and SHALL show the abandoned-reading glyph where [5.1](#5.1) would place the finished-reading glyph. +3. <a name="5.3"></a>Within each section of the Works list, abandoned works SHALL follow every non-abandoned work, and SHALL keep the active sort's order among themselves; this SHALL hold under every sort, with or without a search query, and with or without filters. +4. <a name="5.4"></a>The merge destination picker SHALL show the same glyphs and reduced emphasis on its rows, in its existing order; the unattached-notes group and `WorksSearchFilter`'s matching SHALL NOT change. +5. <a name="5.5"></a>Each status glyph SHALL carry an accessibility label naming its dimension and value in words ("Work: Finished", "Work: On hiatus", "Reading: Finished", "Reading: Abandoned"), and an abandoned row's title element SHALL carry "Reading: Abandoned" in its accessibility label, so that the state is not conveyed by colour or opacity alone. ++### 6. Status Filters++**User Story:** As a reader, I want to filter the Works list by work status and by reading status, so that I can pull up what is still running, what I have finished, or what I dropped.++**Acceptance Criteria:**++1. <a name="6.1"></a>The Works list options menu SHALL gain two single-value filter dimensions, work status and reading status, each offering "Any" plus the three values in a fixed order (`ongoing`, `finished`, `hiatus`; `reading`, `finished`, `abandoned`), combined with the existing type, tag and site dimensions and the search query by AND. +2. <a name="6.2"></a>The two dimensions SHALL behave as the existing three do for the active-filter pill row, the Clear control, the filter empty state, the menu icon's filled state, and the not-stored view-state lifetime (`specs/works-list-options/`), except that a status value's pill and its mention in the empty state SHALL name the dimension ("Work: Finished", "Reading: Finished") so the two `finished` values cannot be confused. +3. <a name="6.3"></a>A status filter value SHALL be offered, and SHALL stay selected, whether or not any work currently carries it. ++### 7. Groups, Duplicates and Merge++**User Story:** As a reader with the same work captured on more than one device or site, I want a status I set to survive the library's reconciliation, so that a collapse or merge never silently loses it.++**Acceptance Criteria:**++1. <a name="7.1"></a>Committing either status or the verdict SHALL write the value to every row of the work's group, as the work's other edited fields are written. +2. <a name="7.2"></a>A non-default status and a non-empty verdict SHALL count as reader-authored content: two rows of a group that disagree on either SHALL form variants for review rather than collapse; the review card SHALL list the differing statuses and verdict among the fields that differ; and resolving the review SHALL write the chosen variant's statuses and verdict to every surviving row. A row carrying only the default statuses and an empty verdict SHALL contribute nothing to that comparison. +3. <a name="7.3"></a>WHEN two works are merged, the merged work SHALL keep the target's statuses and verdict. A non-default source status that differs from the target's SHALL be listed in the merge preview as a discarded field; a non-empty source verdict that differs from the target's SHALL be appended to the merged work's notes in the merge audit block as source notes are, and listed as recorded. The preview's wording SHALL state which discarded fields are recorded in the merged notes and which are dropped. +4. <a name="7.4"></a>A status or verdict changed elsewhere between entering and committing edit mode SHALL be treated as an edit conflict exactly as a change to the work's other edited fields is. ++### 8. Backup and Restore++**User Story:** As a reader, I want my statuses and verdicts in my backups, so that a restore brings back the library I had.++**Acceptance Criteria:**++1. <a name="8.1"></a>A backup archive written by this build SHALL carry both statuses and the verdict for every work under a new archive version pair, and importing it SHALL restore all three exactly. +2. <a name="8.2"></a>Importing an archive written by a pre-feature build SHALL be refused with a message naming both the archive's version pair and the supported one, as an unsupported pair is refused today. +3. <a name="8.3"></a>WHEN a work carries a status value this build does not know, export SHALL refuse, naming the work and the value, as it refuses every other unrepresentable enum value. ++### 9. Library Conversion and Compatibility++**User Story:** As a reader updating the app, I want my library to convert without setup, so that the feature costs me nothing.++**Acceptance Criteria:**++1. <a name="9.1"></a>WHEN the app first opens a library on readiness marker `9`, it SHALL convert it in place with no user action, every existing work reading the default statuses and an empty verdict, and SHALL publish readiness marker `10` only after the converted store validates. +2. <a name="9.2"></a>The app SHALL open a library on marker `9` or `10`; a library on any other marker SHALL be refused with the existing readiness refusal naming the marker, and the recovery SHALL remain the backup archive. +3. <a name="9.3"></a>WHILE the library is on marker `9`, the share extension SHALL refuse to open it with the existing "Open Asterism to finish updating the library" message, and SHALL capture normally once the marker is `10`. +4. <a name="9.4"></a>A pre-feature build opening a library on marker `10` on the same device SHALL meet that build's existing unknown-marker refusal rather than a crash; this build SHALL keep refusing an unknown marker by name so that the same protection holds for the next bump. ++### 10. Accessibility and Layout++**User Story:** As a reader using large text, I want the new controls and marks to stay usable, so that the feature holds at the sizes I read at.++**Acceptance Criteria:**++1. <a name="10.1"></a>Every new control, glyph and field SHALL carry an accessibility identifier and label. +2. <a name="10.2"></a>At `UICTContentSizeCategoryAccessibilityXXXL` on iPhone, the two status pickers and the verdict field SHALL be reachable and operable in edit mode, the confirmation dialog from [3.1](#3.1) SHALL present all three actions, and a work row wearing a work-status glyph and a reading-status glyph SHALL remain hittable in the list. +3. <a name="10.3"></a>The pickers, glyphs, verdict and filters SHALL be available with equivalent behaviour in the compact and wide layouts (`specs/ipad-and-mac-layouts/`), with no platform-specific omission.
diff --git a/specs/work-and-reading-status/tasks.md b/specs/work-and-reading-status/tasks.mdnew file mode 100644index 0000000..584341d--- /dev/null+++ b/specs/work-and-reading-status/tasks.md@@ -0,0 +1,220 @@+---+references:+ - specs/work-and-reading-status/requirements.md+ - specs/work-and-reading-status/design.md+ - specs/work-and-reading-status/decision_log.md+---+# Work and Reading Status Tasks++## Schema V10 and bootstrap++- [x] 1. Move the tests' unrecognised marker literal from 10 to 99 <!-- id:0yf84ph -->+ - `BootstrapClassifierTests` (including the `.unrecognisedText` axis case), `BootstrapActionTests`, `MarkerContractTests`, `BootstrapStateCoverageTests` write `"10\n"` as the unknown marker; change every one to `"99\n"` so the suites keep testing an unknown digit once 10 is live (Q28).+ - `make test-core` stays green before any other change.+ - Stream: 1+ - Requirements: [9.2](requirements.md#9.2)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift++- [x] 2. Write failing tests for the status enums, the three Work columns and schema V10 <!-- id:0yf84pi -->+ - `ModelContractTests`: entity list and version stamps at V10, a fresh `Work` reads `.ongoing`/`.reading`/`""` and its raw columns hold `"ongoing"`/`"reading"`, `declaringSnapshots == [AsterismSchemaV9.swift, AsterismSchemaV10.swift]`; the pinned file sets in `ModelContractTests` and `MultiSiteReadPathTests` name the frozen `AsterismSchemaV9.swift`.+ - `EnumTolerancePolicyTests`: an unknown and an empty raw read as the default through both accessors, and a write goes through.+ - `FrozenLibraryPathTests`: schema buckets become V9/V10, stage count stays 1, V10 == 10.0.0.+ - `DomainEnums`: raw values and `CaseIterable` order pinned, `ReadingStatus.isDone`.+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [2.1](requirements.md#2.1), [2.7](requirements.md#2.7), [8.3](requirements.md#8.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift, specs/work-and-reading-status/design.md++- [x] 3. Implement the enums and columns, freeze V9 and declare V10 with its plan <!-- id:0yf84pj -->+ - `DomainEnums.swift`: `WorkStatus`, `ReadingStatus` on the `TitleProvenance` shape, `ReadingStatus.isDone`.+ - `Models.swift`: three defaulted columns and two `ToleratedEnum` accessors on `Work`; the file opens `extension AsterismSchemaV10` and every typealias repoints.+ - `AsterismSchemaV9.swift` becomes the frozen snapshot (stored columns and `@Relationship` only, `public init() {}`, doc header restating the frozen-by-reference list with the two new raw defaults).+ - New `AsterismSchemaV10.swift` with the ten models and `AsterismV10MigrationPlan` = `[V9, V10]`, one lightweight stage.+ - Delete `AsterismSchemaV8.swift`.+ - Repoint both Source `Schema(versionedSchema:` sites (`LibraryRepository+Bootstrap.swift:414`, `LibraryRepository+BackupImportGates.swift:43`) and the 33 test sites to V10 (grep after the freeze).+ - `Work.init` and the `create` doors are untouched.+ - Blocked-by: 0yf84pi (Write failing tests for the status enums, the three Work columns and schema V10)+ - Stream: 1+ - Requirements: [1.1](requirements.md#1.1), [1.3](requirements.md#1.3), [2.1](requirements.md#2.1), [2.7](requirements.md#2.7), [9.1](requirements.md#9.1)+ - References: Packages/AsterismCore/Sources/AsterismCore/DomainEnums.swift, Packages/AsterismCore/Sources/AsterismCore/Models.swift, Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV9.swift, Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV8.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift, docs/agent-notes/schema-migration.md++- [x] 4. Write MarkerGenerationTenTests and move the marker suites one generation <!-- id:0yf84pk -->+ - `MarkerGenerationTenTests` replaces `MarkerGenerationNineTests`: a `"9"` store opens, validates and publishes `"10"` in that order; a validation failure leaves `"9"`; the extension refuses `"9"` with the finish-updating message and anything else with the not-initialized message; `"8"` is refused naming the digit.+ - `BootstrapClassifierTests`' marker axis gains `ten`, `eight` joins the retired list; `MarkerContractTests`, `BootstrapStateCoverageTests`, `CertificationPathTests`, `BootstrapActionTests`, `MirroringBootstrapLifecycleTests` and `FrozenLibraryPathTests` move their `"8"`/`"9"` literals one generation.+ - Red until task 5.+ - Blocked-by: 0yf84ph (Move the tests' unrecognised marker literal from 10 to 99), 0yf84pj (Implement the enums and columns, freeze V9 and declare V10 with its plan)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationNineTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift++- [x] 5. Bump the readiness marker constants to 9 and 10 <!-- id:0yf84pl -->+ - `LibraryRepository+Bootstrap.swift`: `laggingOpenableMarkerVersion = "9"`, `extensionOpenableMarkerVersion = "10"`; the set stays two elements.+ - No structural change to the classifier or `act(on:)`; rewrite the file header, the `.markerLagging` arm's log line and comments, `BootstrapState`'s doc comments and `abbreviatedMarkerText`'s one-character remark.+ - `publishReadiness` is unversioned.+ - Blocked-by: 0yf84pk (Write MarkerGenerationTenTests and move the marker suites one generation)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1), [9.2](requirements.md#9.2), [9.3](requirements.md#9.3), [9.4](requirements.md#9.4)+ - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift++- [x] 6. Write V9RecordedStoreFixture and V9RecordedStoreTests over the frozen V9 snapshot <!-- id:0yf84pm -->+ - Fixture seeds through `Schema(versionedSchema: AsterismSchemaV9.self)` with config name `AsterismV3`, one row per entity plus the same Entry and TitlePattern inventory as the V8 fixture, no values for the three new columns; create, seed, save, release before returning, and a doc comment that repeats the ordering argument and says the live-within-frozen subset relation no longer holds.+ - Tests: `openForApp` moves the marker `"9"` to `"10"` and the recorded schema `9.0.0` to `10.0.0`, every migrated `Work` **raw column** equals the default spelling, survivors survive, the extension refuses `"9"`.+ - Delete `V8RecordedStoreFixture` and `V8RecordedStoreTests`; `V4RecordedStoreTests` unchanged apart from its floor note.+ - Blocked-by: 0yf84pl (Bump the readiness marker constants to 9 and 10)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1), [9.3](requirements.md#9.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreFixture.swift, Packages/AsterismCore/Tests/AsterismCoreTests/V8RecordedStoreTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift, docs/agent-notes/schema-migration.md++- [x] 7. Move the library graph baseline to format 7 <!-- id:0yf84pn -->+ - `LibraryGraphBaselineTests` serialises `workStatusRaw`, `readingStatusRaw`, `verdict` after `titleProvenanceRaw`; header comment and `format 7`; re-record `specs/retire-migration-chain/library-graph-baseline.txt` and review the diff line by line rather than regenerating blind (Q35).+ - Blocked-by: 0yf84pj (Implement the enums and columns, freeze V9 and declare V10 with its plan)+ - Stream: 1+ - Requirements: [9.1](requirements.md#9.1)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift, specs/retire-migration-chain/library-graph-baseline.txt++## Authored-content write chain++- [x] 8. Write failing tests for the snapshot, authored content, edit basis, updateWork, reconciler and creation defaults <!-- id:0yf84po -->+ - `GroupOrderingTests`: `isBare` true only at all three defaults, a non-default status or non-empty verdict forms a variant, each field changes `orderComponents`.+ - `WorkSnapshotMembershipTests`/`GroupFetchTests`: the snapshot carries the row's values and a split group presents the carrier's.+ - `FanOutWriteTests`/`RepositoryWorksTests`: `updateWork` lands all three on every row of the group and trims the verdict; a status-only draft is a real edit.+ - `PostCollapseRedirectTests`: a survivor differing on a status refuses with `survivorDiverged`.+ - `DuplicateReconcilerTests`: a carrier's non-default status propagates to a sibling; a carrier on the defaults never overwrites a sibling's `abandoned` or verdict.+ - Creation defaults: `RepositoryWorksTests` for New Work and Move To a new work, `RepositoryCaptureTests` for a capture that mints a work and one into an abandoned work leaving all three untouched.+ - Blocked-by: 0yf84pj (Implement the enums and columns, freeze V9 and declare V10 with its plan)+ - Stream: 1+ - Requirements: [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.4](requirements.md#7.4)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/PostCollapseRedirectTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/RepositoryCaptureTests.swift++- [x] 9. Thread the three fields through the write chain to pass the tests <!-- id:0yf84pp -->+ - Per the design's parity audit: `WorkSnapshot` three defaulted fields; `snapshot(_:types:)` and both arms of `snapshot(_ group:)`; `WorkAuthoredContent` fields, `isBare`, `orderComponents` (`.string(rawValue)` / `.string(verdict)`); `authoredContent(of:)`; `WorkMetadataDraft` three **required** parameters (Q40) and every construction site updated, including the two `AppLibraryModel` fixture seeds; `WorkEditBasis` fields, `init(work:)`, `matches`; `updateWork` writes all three in the every-row loop with `normalizeVerdict` trimming; `DuplicateReconciler.apply` arms on the `genreTags` shape with the non-default guard.+ - Keep `WorkAuthoredContent` and `WorkSnapshot` inits defaulted.+ - `LibraryValidator` untouched.+ - Blocked-by: 0yf84po (Write failing tests for the snapshot, authored content, edit basis, updateWork, reconciler and creation defaults)+ - Stream: 1+ - Requirements: [2.4](requirements.md#2.4), [2.6](requirements.md#2.6), [7.1](requirements.md#7.1), [7.2](requirements.md#7.2), [7.4](requirements.md#7.4)+ - References: Packages/AsterismCore/Sources/AsterismCore/Snapshots.swift, Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift, Packages/AsterismCore/Sources/AsterismCore/RepositoryDrafts.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Groups.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift++## Duplicate resolution and merge++- [x] 10. Write failing tests for duplicate resolution and merge over the three fields <!-- id:0yf84pq -->+ - `DuplicateResolutionTests`: `differingWorkFields` names `.workStatus`, `.readingStatus`, `.verdict`; `choice` prefers the surviving row's value; resolution writes the chosen variant's three to every survivor; a losing variant's verdict lands in the survivors' notes under the audit block; a hidden verdict is shown in the choice.+ - `WorkMergePlannerTests`/`WorkMergeRepositoryTests`: target keeps its three; a non-default differing source status is discarded with `recordedInNotes == false`; a differing non-empty source verdict is in the audit block and discarded with `recordedInNotes == true`, including a side differing by verdict alone; identical or default values produce no entry; `WorkMergeOutcome` carries the target's values.+ - App: `DuplicateSurfaceTests` for the three view arms, `WorkMergeModelTests` for the two captions and the six labels.+ - Blocked-by: 0yf84pp (Thread the three fields through the write chain to pass the tests)+ - Stream: 1+ - Requirements: [2.5](requirements.md#2.5), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergePlannerTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/WorkMergeRepositoryTests.swift, Asterism/AsterismTests/DuplicateSurfaceTests.swift, Asterism/AsterismTests/WorkMergeModelTests.swift++- [x] 11. Implement the resolution fields, the shared fold, the merge fields and both review surfaces <!-- id:0yf84pr -->+ - Core: `DuplicateResolutionField` cases; `WorkVariantChoice` fields and `choice(_:rows:types:)`; `differingWorkFields`; survivor loop writes the carrier's three; `WorkVariantSide` fields and inits; `WorkVariantUnion.fold` per the design's merge section (unconditional target seeding, non-default differing source statuses discarded, differing verdict into `WorkMergeAuditFormatter.block(sourceTitle:discardedWorkURLs:sourceVerdict:sourceNotes:)` as a `Verdict:` line before the notes, `verdictRecorded` joins the block gate); `WorkMergeField` six cases and `recordedInNotes`; `WorkMergeOutcome` three defaulted parameters; `WorkMergePlanner` passes the target's.+ - App: `DuplicateResolutionView.workVariantContent` three arms; `WorkMergeView` discarded captions from `recordedInNotes`, `fieldLabel` six labels, the row `accessibilityLabel` at :227 and the audit-block label at :247 reworded.+ - Blocked-by: 0yf84pq (Write failing tests for duplicate resolution and merge over the three fields)+ - Stream: 1+ - Requirements: [2.5](requirements.md#2.5), [7.2](requirements.md#7.2), [7.3](requirements.md#7.3)+ - References: Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift, Packages/AsterismCore/Sources/AsterismCore/WorkVariantUnion.swift, Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift, Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift, Asterism/Asterism/Views/DuplicateResolutionView.swift, Asterism/Asterism/Views/WorkMergeView.swift++## Backup archive 9 over 10++- [x] 12. Write failing archive tests for the 9 over 10 generation <!-- id:0yf84ps -->+ - `git mv` `BackupV8Fixtures` and `BackupV8ArchiveTests` to V9 names and write against the renamed types first (red): round-trip equality for the three fields with the enums typed on the wire; `(8, 9)` refused naming both pairs, `(9, 10)` accepted; `BackupExportDegradedRefusalTests` refuses an unknown and an empty `workStatusRaw` by work id and value; `BackupGoldenExportTests` expects `backup-9-10-golden.json` and `formatVersion == 9`, `schemaVersion == 10`; the literal JSON substring pin in the fixtures gains the three keys.+ - `FrozenLibraryPathTests`' archive bucket names the V9 set.+ - Blocked-by: 0yf84pp (Thread the three fields through the write chain to pass the tests)+ - Stream: 1+ - Requirements: [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3)+ - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackupV8Fixtures.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupV8ArchiveTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift, specs/rule-citation-by-uuid/tasks.md++- [x] 13. Rename the archive set to BackupV9, carry the three fields and record the new golden <!-- id:0yf84pt -->+ - `BackupImportTransactionTests`: a work materialised from an archive record carries the record's three statuses, and the creation defaults apply wherever a status-less record is still reachable (Req 2.6, Q50).+ - Follow `rule-citation-by-uuid` task 7.2: `git mv` `BackupV8Types/Codec/Exporter.swift` to `BackupV9*`, rename every `BackupV8X` symbol, `formatVersion = 9`, `schemaVersion = 10`, codec label `V9`, gate literal stays `multi-site`.+ - `BackupV9Work` gains `workStatus: WorkStatus`, `readingStatus: ReadingStatus`, `verdict: String`, required.+ - `BackupArchiveProjection` maps them and adds two `require` lines beside `titleProvenance`'s; `LibraryRepository+ConfirmImport.apply(_:to:)` writes them; `BackupImporter.supportedVersions` follows the constants and its doc comment says 9/10.+ - Sweep the format-label strings and comment-only files the precedent lists, plus `SettingsBackupModel` and `AppLibraryModel`.+ - Delete `backup-8-9-golden.json`, record `backup-9-10-golden.json` with `ASTERISM_RECORD_GOLDEN=1`, re-run without.+ - Blocked-by: 0yf84ps (Write failing archive tests for the 9 over 10 generation)+ - Stream: 1+ - Requirements: [8.1](requirements.md#8.1), [8.2](requirements.md#8.2), [8.3](requirements.md#8.3)+ - References: Packages/AsterismCore/Sources/AsterismCore/BackupV8Types.swift, Packages/AsterismCore/Sources/AsterismCore/BackupV8Codec.swift, Packages/AsterismCore/Sources/AsterismCore/BackupV8Exporter.swift, Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift, Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift, Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-8-9-golden.json++## Works list and filters++- [x] 14. Write failing tests for status presentation, the abandoned-last partition, the two filters and the row labels <!-- id:0yf84pu -->+ - New `WorkStatusPresentationTests`: names, symbols (`flag.checkered`, `pause.circle`, `checkmark`, `book.closed`), hues, accessibility labels, verdict prompts per case.+ - `WorksListOptionsTests`: abandoned-last under every sort composed with `.oldest`'s per-section reversal, stable among the abandoned works, both filters AND with the rest, `pruned` leaves a status alone, `WorksFilter` init defaults; rename the `three dimensions are ANDed` case.+ - `WorksFilterPresentationTests`: `Work: Finished` / `Reading: Finished` pills and empty-state text, `works-filter-work-status-any` identifiers.+ - `WorksRowPresentationTests`: the `.+ - Work: …`/`.+ - Reading: …` clauses beside a two-hostname label.+ - `TestFixtures.makeWork` gains three defaulted parameters.+ - Blocked-by: 0yf84pp (Thread the three fields through the write chain to pass the tests)+ - Stream: 2+ - Requirements: [4.3](requirements.md#4.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3)+ - References: Asterism/AsterismTests/WorksListOptionsTests.swift, Asterism/AsterismTests/WorksFilterPresentationTests.swift, Asterism/AsterismTests/WorksRowPresentationTests.swift, Asterism/AsterismTests/Helpers/TestFixtures.swift++- [x] 15. Implement WorkStatusPresentation, the partition, the filter dimensions, the row glyphs and dimming <!-- id:0yf84pv -->+ - New `Asterism/Asterism/Views/WorkStatusPresentation.swift` (`WorkStatusPresentation`, `ReadingStatusPresentation`; add to the app target per `docs/agent-notes/xcode-project-file.md`).+ - `WorksSort.apply` ends with two concatenated `filter` calls, never `partition(by:)`.+ - `WorksFilter` two stored properties and nil-defaulted init parameters, `isActive`/`apply`/`matches`; `pruned` untouched for them.+ - `WorksFilterPresentation` qualified labels and identifiers, statics `nonisolated`.+ - `WorksView.optionsMenu` two `filterPicker` calls under Site iterating `allCases`.+ - `WorkRow`: glyphs after the type tag in the pill's text font, `.opacity(ConstellationRecipes.knockdownOpacity)` and `secondaryText` title on abandoned rows, identifiers `work-status-glyph`/`reading-status-glyph`, the `work-title` label clause.+ - `WorksRowPresentation.openLabel` and `WorkMergeView.destinationLabel` append the `.+ - `DuplicateResolutionView`'s three status arms (`workStatusLine`, `readingStatusLine`, `verdictLine`, landed in task 11 with their own words) delegate to `WorkStatusPresentation`/`ReadingStatusPresentation` so the two names tables become one (Q54); `DuplicateSurfaceTests.workVariantStatusArmsNameEveryValue` keeps pinning the spelling.+ - `-joined clauses.+ - Blocked-by: 0yf84pu (Write failing tests for status presentation, the abandoned-last partition, the two filters and the row labels)+ - Stream: 2+ - Requirements: [4.3](requirements.md#4.3), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [6.3](requirements.md#6.3), [10.1](requirements.md#10.1)+ - References: Asterism/Asterism/Views/WorkTypePresentation.swift, Asterism/Asterism/ViewModels/WorksListOptions.swift, Asterism/Asterism/Views/WorksView.swift, Asterism/Asterism/Views/WorkMergeView.swift, Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift, docs/agent-notes/xcode-project-file.md, Asterism/Asterism/Views/DuplicateResolutionView.swift++## Work detail and the finished-reading rule++- [x] 16. Write failing WorkDetailModel tests for the drafts, the transition table and the commit-time prompt <!-- id:0yf84pw -->+ - `WorkDetailModelTests`: every row of the design's transition table — the prompt on any status to `.finished` under a non-finished work, the re-tap no-op, the auto-revert on leaving `.finished` with the verdict kept, the Q39 restore when the work returns to `.finished`, the commit-time prompt only when the pair differs from the snapshot, `thenCommits` follow-through for both actions, cancel leaves drafts as they were, a URL refusal on the follow-through leaves the resolved drafts and writes nothing; `hasUnsavedChanges` on a status-only, verdict-only and typed-then-hidden verdict change; `restoreDraftsFromSnapshot` resets all three; `save` sends all three in the draft and the basis, the nil-snapshot fallback basis included; `FinishedReadingPrompt.message` text.+ - Blocked-by: 0yf84pv (Implement WorkStatusPresentation, the partition, the filter dimensions, the row glyphs and dimming)+ - Stream: 2+ - Requirements: [1.2](requirements.md#1.2), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5)+ - References: Asterism/AsterismTests/WorkDetailModelTests.swift, Asterism/Asterism/ViewModels/WorkDetailModel.swift, specs/work-and-reading-status/design.md++- [x] 17. Implement the detail model transitions, edit-mode controls, meta-line items, verdict paragraph and dialog <!-- id:0yf84px -->+ - `WorkDetailModel`: `draftWorkStatus`, `draftReadingStatus`, `draftVerdict`, `autoRevertedReading`, `finishedReadingPrompt`, `setDraftWorkStatus(_:)`, `setDraftReadingStatus(_:)`, `resolveFinishedReadingPrompt(_ prompt:choosing:) async (Q62)`, the check first in `commitEditing()`, `FinishedReadingPrompt` on the `WorkDeletionPrompt` shape.+ - `WorkDetailView`: two status cards with visible `.caption` semibold `secondaryText` captions over `ConstellationSegmentedControl` (identifiers `work-detail-work-status-{raw}`, `work-detail-reading-status-{raw}`), the verdict card with the prompt caption over `TextField(axis: .vertical).lineLimit(3...6)` (`work-detail-verdict-field`) before Tags; meta-line `Label` items `work-detail-status-work`/`-reading`; the `Verdict` paragraph between the tag row and the notes (`work-detail-verdict`); `.confirmationDialog("Mark as finished?", presenting: model.finishedReadingPrompt)` on the `List` with identifiers `work-detail-finished-mark-work`/`-abandon`/`-cancel`.+ - No platform seam.+ - Blocked-by: 0yf84pw (Write failing WorkDetailModel tests for the drafts, the transition table and the commit-time prompt)+ - Stream: 2+ - Requirements: [1.2](requirements.md#1.2), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.5](requirements.md#2.5), [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [10.1](requirements.md#10.1), [10.3](requirements.md#10.3)+ - References: Asterism/Asterism/ViewModels/WorkDetailModel.swift, Asterism/Asterism/Views/WorkDetailView.swift, Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift, docs/asterism-style-guide.md++## UI fixture and journeys++- [x] 18. Seed statuses into the works-options fixture and write the UI journeys <!-- id:0yf84py -->+ - One journey asserts the row's status glyph identifiers (`work-status-glyph`, `reading-status-glyph`) sit inside the row after the type tag and that a row with glyphs is no taller than one without (Req 5.1, Q61).+ - `seedWorksOptionsWork` takes the three fields and passes them through its final `updateWork`: Marrow Lane abandoned with a verdict, Ashfall hiatus, Zephyr Court finished/finished with a verdict, Quill Harbour on defaults; rewrite the four expected-order constants.+ - `WorksListOptionsUITests`: abandoned-last under each sort, both filters via `chooseWorksOption`, the qualified pill text, the dimmed row.+ - New `WorkDetailStatusUITests`: edit Marrow Lane to reading finished, meet the dialog, mark the work finished, save, read both meta-line items and the verdict.+ - `AccessibilityJourneyUITests` at XXXL: a status filter, both capsules, the verdict field, the dialog's three buttons, a row wearing both glyphs.+ - `WideLayoutUITests`: the meta-line items on iPad.+ - The merge journey reads a destination row's status clause.+ - `make test-ui` and `make test-ui-ipad`.+ - Blocked-by: 0yf84pr (Implement the resolution fields, the shared fold, the merge fields and both review surfaces), 0yf84pv (Implement WorkStatusPresentation, the partition, the filter dimensions, the row glyphs and dimming), 0yf84px (Implement the detail model transitions, edit-mode controls, meta-line items, verdict paragraph and dialog)+ - Stream: 1+ - Requirements: [3.1](requirements.md#3.1), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [5.5](requirements.md#5.5), [6.1](requirements.md#6.1), [6.2](requirements.md#6.2), [10.1](requirements.md#10.1), [10.2](requirements.md#10.2), [10.3](requirements.md#10.3)+ - References: Asterism/Asterism/ViewModels/AppLibraryModel.swift, Asterism/AsterismUITests/WorksListOptionsUITests.swift, Asterism/AsterismUITests/AccessibilityJourneyUITests.swift, Asterism/AsterismUITests/UIJourneySupport.swift, docs/agent-notes/testing.md++## Documentation and verification++- [x] 19. Update the style guide, design doc, agent notes, overview and changelog <!-- id:0yf84pz -->+ - `design.md`'s transition table and Components block record the landed dialog shape: `resolveFinishedReadingPrompt(_ prompt:choosing:) async` carries the prompt (Q62), Cancel is the sync `cancelFinishedReadingPrompt()`, and `FinishedReadingResolution` has no cancel case.+ - `design.md`'s dialog section records Q65: on iOS 26 the `confirmationDialog` is an anchored popover at every size and the declared Cancel is not drawn on the phone; the third action is the dismiss region. Do not amend `requirements.md` — that is the owner's decision.+ - `docs/asterism-style-guide.md` §7 (meta-line items, row glyphs, abandoned knockdown, the two captions) and §8 (the four symbols); `docs/asterism-design.md` §5.2 and §6; `docs/agent-notes/schema-migration.md` current state at V10, the History list, the recorded-store warning now lived; `docs/agent-notes/rule-wire-format.md` `BackupV9*`; `specs/OVERVIEW.md` row and section; `CHANGELOG.md`.+ - Blocked-by: 0yf84pt (Rename the archive set to BackupV9, carry the three fields and record the new golden), 0yf84py (Seed statuses into the works-options fixture and write the UI journeys)+ - Stream: 1+ - Requirements: [4.3](requirements.md#4.3), [5.2](requirements.md#5.2), [9.1](requirements.md#9.1)+ - References: docs/asterism-style-guide.md, docs/asterism-design.md, docs/agent-notes/schema-migration.md, docs/agent-notes/rule-wire-format.md, specs/OVERVIEW.md, CHANGELOG.md++- [x] 20. Run the full suites and the Mac build and record the verification <!-- id:0yf84q0 -->+ - `make test-core`, `make test-quick` (includes `make build-mac`), `make test-ui`, `make test-ui-ipad`; no new compiler warnings per `docs/agent-notes/testing.md`; `make test-performance-m4` (host-only, safe, ~21 min) once to confirm the bands hold with the three columns and the wider `orderComponents`.+ - Record the outcome in `specs/work-and-reading-status/verification-run.md`, not in tasks.md.+ - The Mac app is never launched.+ - Blocked-by: 0yf84pz (Update the style guide, design doc, agent notes, overview and changelog)+ - Stream: 1+ - Requirements: [10.3](requirements.md#10.3)+ - References: Makefile, docs/agent-notes/testing.md, specs/drop-superseded-columns/verification-run.md
diff --git a/specs/work-and-reading-status/verification-run.md b/specs/work-and-reading-status/verification-run.mdnew file mode 100644index 0000000..781e87b--- /dev/null+++ b/specs/work-and-reading-status/verification-run.md@@ -0,0 +1,272 @@+# Verification Run: Work and Reading Status++Task 20's evidence, recorded here rather than in `tasks.md`, which `rune` owns.++**Date**: 2026-09-05+**Host**: the project machine, macOS 26, Apple Silicon. **Host and simulator+only.** No device target was run and none may be: `make test-performance-m4-recent`,+`make install`, `make run` and `xcrun devicectl` all touch the owner's phone, and+the Mac app is a device target too — `make build-mac` compiles it and nothing+here opened, launched or installed it (`CLAUDE.md`).++**One run is not a baseline.** `CLAUDE.md` says so and the suites' history says+it louder: three consecutive release runs of unchanged code once measured+0.7805 s, 1.2789 s and 0.7389 s. `make test-performance-m4` was run **once**+here, as task 20 asks, so §4 compares a single run against a recorded band and+says so wherever the difference is small enough for that to matter.++The performance comparison column throughout is+[`../drop-superseded-columns/verification-run.md`](../drop-superseded-columns/verification-run.md)+(2026-08-28), which is the last full recording.++---++## 1. What was run++| Target | Outcome | Wall time | Reruns |+|---|---|---|---|+| `make test-core` (run 1, via `run_silent`) | **exit 0** | 97 s | — |+| `make test-core` (run 2, unwrapped for the record) | **exit 2** — one known flake, [§2.1](#21-the-one-test-core-flake) | 90 s | — |+| `make test-core` (run 3, the rerun) | **exit 0**, 222 suites, 2,301 checks | 90 s | 1 rerun, and it is reported rather than hidden |+| `make test-quick` (includes `make build-mac`) | **exit 0**, 1,146 checks | 72 s | — |+| `make build-ios` (forced full recompile, warning grep) | **exit 0**, 252 files compiled | 47 s | — |+| `make test-ui` | **exit 2** — 116 tests, 2 skipped, **3 failures, 0 unexpected**, all three the pre-existing `M4ScaleRecentPerformanceUITests` trio ([§3](#3-the-three-pre-existing-test-ui-failures)) | 3,055 s (50 m 55 s) | — |+| `make test-ui-ipad` | **exit 0**, 16 tests, 0 failures | 269 s | — |+| `make test-performance-m4` | **exit 0**, 28 tests in 5 suites, **8 known issues** | 1,395 s (23 m 15 s), of which 1,077 s is test time and the rest the release build | — |++`make build-mac` ran as `test-quick`'s prerequisite and compiled the app and the+Mac share extension for macOS. **The product was never opened, launched or+installed.**++### 1.1 The two live Apple Intelligence calls did not degrade++Both `AsterismIntelligence` live-model tests passed on this host rather than+falling back to a `withKnownIssue`: "A live model call returns a decodable+`ExtractionResult` for one note" (10.2 s) and "A live model call returns a+decodable `RuleProposal` for a two-example corpus" (11.1 s), in both green+`test-core` runs. The host has the model available; a host without it would have+recorded two known issues here and still exited 0.++## 2. Reruns, and what caused the one that was needed++### 2.1 The one `test-core` flake++Run 2 failed with a single issue in `BootstrapActionTests`:++> ✖ "A failed open leaves the store, the marker and every artefact unchanged"+> with 7 test cases (0.058 seconds) 1 issue(s)++This is the known flake `docs/agent-notes/testing.md` records under "Known flaky family: the store-digest comparisons" — it names this exact cell — and both the run before it+and the rerun after it were green over the same source. Nothing was changed+between the three runs. Recorded rather than quietly re-run: the bar is that a+rerun is reported, not that it is invisible.++No other target needed a rerun. `make test-ui`'s three failures are not flakes —+see below.++## 3. The three pre-existing `make test-ui` failures++`make test-ui` reports **116 tests, 2 skipped, 3 failures (0 unexpected)**. All+three are the `M4ScaleRecentPerformanceUITests` trio recorded in+[`../ipad-and-mac-layouts/verification-run.md`](../ipad-and-mac-layouts/verification-run.md)+(§ around line 440), and this feature touches none of them:++- `testSeededScaleM4ScenarioReachesRecent`+- `testSeededScaleM4DuplicateSiteRowsScenarioReachesRecent`+- `testTruncationFooterOpensWorksRoot`++The seeded-scale scenario times out before Recent appears on the simulator. They+are named here as pre-existing, not as passing.++### 3.1 The feature's own journeys, all green++| Journey | Time |+|---|---|+| `testTheRowGlyphsFollowTheTypeTagAndCostTheRowNoHeight` | 29.6 s |+| `testTheAbandonedWorkSinksUnderAQueryAndAFilterAndSaysSo` | 32.7 s |+| `testTheStatusFiltersNarrowTheListAndNameTheirDimension` | 53.0 s |+| `testMarkingAnAbandonedWorkFinishedMeetsTheDialogAndShowsBothStatuses` | 35.4 s |+| `testUnfinishingTheWorkStepsTheReadingStatusBack` | 21.2 s |+| `testCancellingTheFinishedReadingDialogLeavesTheDraftAlone` | 22.3 s |+| `testTheMergePickerNamesEachDestinationsStatuses` | 42.6 s |+| `testTheStatusControlsAndDialogStayOperableAtLargestDynamicType` | 53.9 s |++`make test-ui-ipad` ran 16 tests with no failures, so Req 10.3's "no+platform-specific omission" holds on the wide layout as well as the phone.++## 4. `make test-performance-m4`++**exit 0**, 28 tests in 5 suites, **8 known issues** — the steady-state count+since `drop-superseded-columns`, unchanged by this feature. The eight are Req+10.1's settling pass, Req 5.4's three capture-projection arms, Req 5.5's three+diagnosis re-derivations, and the full-tier no-op reconcile. Every one has a+regression ceiling asserted *outside* its known-issue block, and **no ceiling was+reached**, which is why the target exits 0.++**Nothing was re-banded and no ceiling was adjusted.** The question task 20 asks+is whether the three new `Work` columns and the wider `orderComponents` moved+anything, and the answer is that they moved several labels by 2–6% in both+directions, all of it inside existing bounds.++### 4.1 The eight known issues++| Measurement | This run | `drop-superseded-columns` | Bound | Verdict |+|---|---|---|---|---|+| `duplicate-settling-pass` | 7.413 s (n=10, spread 1.03×) | 7.347–7.411 s | 2 s budget (known issue), 11 s ceiling | breached 3.71×, **in ceiling**; 0.03% above the recorded band's top, which is inside the spread of either run |+| `reconcile-noop-coherent` | 0.0301 s (n=20, spread 1.09×) | 0.0296–0.0302 s | 10 ms budget (known issue), 100 ms ceiling | breached 3.01×, **in band**, unchanged |+| `capture-projection-duplicateSiteRows` | 0.1751 s | 0.169 s | 100 ms budget (known issue), 250 ms ceiling | breached 1.75×, in ceiling; **+3.6%** |+| `capture-projection-siteMissing` | 0.1691 s | 0.160 s | as above | breached 1.69×, in ceiling; **+5.7%** |+| `capture-projection-duplicateIdentity` | 0.1762 s | 0.167 s | as above | breached 1.76×, in ceiling; **+5.5%** |+| `diagnosis-refresh-foreground` | 0.2849 s | 0.276 s | 250 ms budget (known issue), 400 ms ceiling | breached 1.14×, in ceiling; **+3.2%** |+| `diagnosis-refresh-after-write` | 0.2849 s | 0.276 s | as above | breached 1.14×, in ceiling; **+3.2%** |+| `diagnosis-refresh-duplicateSiteRows` | 0.2856 s | 0.276 s | as above | breached 1.14×, in ceiling; **+3.5%** |++The three capture-projection arms and the three diagnosis re-derivations are the+six labels that moved most, and they moved the same way, by 3–6%. That is the+expected direction for this change: all six walk `Work` rows that are now three+columns wider, and the capture projection additionally builds `orderComponents`,+which gained three parts (Q29). None of them approaches its ceiling — the widest+is 1.76× a budget under a 250 ms ceiling it sits 30% below — so **the drift is+reported, not budgeted**, exactly as `drop-superseded-columns` reported its+export-projection rise.++### 4.2 Every other label++Medians against `drop-superseded-columns` §4 (run A). Nothing here left a budget+or a ceiling.++| Measurement | This run | Previous | Δ | Bound | Verdict |+|---|---|---|---|---|---|+| `open-coherent` | 0.724 s | 0.743 s | −2.6% | 1 s budget | in budget |+| `open-duplicateSiteRows` | 0.724 s | 0.747 s | −3.1% | 1 s budget, ≤ 1.25× ratio | in budget, ratio **1.000×** |+| `open-siteMissing` | 0.341 s | 0.336 s | +1.5% | 1 s budget | in budget |+| `open-duplicateIdentity` | 0.726 s | 0.747 s | −2.8% | 1 s budget | in budget |+| `extension-open-and-validate` | 0.726 s | 0.749 s | −3.1% | 1 s budget | in budget |+| `store-level-validation` | 0.726 s | 0.742 s | −2.2% | 1 s budget | in budget |+| `record-counts-duplicate-free` | 0.236 s | 0.232 s | +1.7% | 3 s ceiling | in ceiling |+| `membership-reconcile-noop` | 0.392 s | 0.380 s | +3.1% | 800 ms ceiling | in ceiling |+| `membership-heal-full` | 1.731 s | 1.704 s | +1.6% | 5 s ceiling | in ceiling |+| `reconcile-noop-arrival` | 0.0300 s | 0.0301–0.0303 s | −0.5% | — | tiers still measure the same thing |+| `duplicate-arrival-pass-gated` | 0.0297 s | 0.0301–0.0314 s | −3.4% | — | as above |+| `duplicate-observation-pass` | 1.021 s | 1.008–1.025 s | in band | 2 s budget | **in budget**, still retired as a known issue |+| `reconcile-worst-case-consolidation` | 39.73 s | 40.08 s | −0.9% | 55 s ceiling | in ceiling |+| `merge-destinations` | 1.307 s | 1.371 s | −4.7% | 3 s class ceiling | in ceiling |+| `works-snapshot-duplicate-free` | 1.643 s | 1.704 s | −3.6% | 3 s ceiling | in ceiling |+| `recent-coherent` | 0.877 s | 0.885 s | −0.9% | 2 s budget | in budget |+| `recent-duplicateSiteRows` | 0.875 s | 0.886 s | −1.2% | 2 s budget, ≤ 1.25× ratio | in budget, ratio 0.998× |+| `recent-publication-duplicate-free` | 0.874 s | 0.886 s (run B; run A's 1.029 s was noise) | −1.4% | 2 s budget | in budget |+| `backup-projection-duplicate-free` | 1.446 s | 1.485 s | −2.6% | reported only | the Q18 fan-out cost has not grown |+| `capture-rule-application` (4 arms) | ~0.07 ms | ~0.07 ms | — | 100 ms budget | in budget |+| `complete-preview-expanded` | 0.0787 s | 0.079 s | — | 1 s budget | in budget |+| `complete-preview-collapsed` | 0.0296 s | 0.030 s | — | 1 s budget | in budget |+| `edit-ack-expanded` / `-collapsed` | 17 µs / 6 µs | 6–15 µs | — | 100 ms budget | in budget |+| `character-ranking-200x50` | 2.25 ms | added after that recording | — | its own budget | in budget |++**The three whole-store labels that fell 2–3%** (`open-*`,+`extension-open-and-validate`, `store-level-validation`) went the *opposite* way+to the six in §4.1. Three defaulted scalars on `Work` should not make an open+faster, and this is one run against one run, so the fall is read as host variance+rather than as an effect. The point of the table is that nothing rose towards a+bound, and nothing did.++### 4.3 No new compiler warnings++Checked the way `docs/agent-notes/testing.md` requires — an unwrapped build,+grepped — rather than by a green wrapped run, which proves nothing about+warnings.++Both build intermediates trees (`Asterism.build`, `AsterismCore.build`) were+deleted first so the compile was real: 252 files compiled. `make build-ios`+reports **three** warnings, and **all three sit on lines this branch never+touched**:++- `Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift:344` —+ captured `var self` in concurrently-executing code. The file is not in this+ branch's diff at all.+- `Asterism/Asterism/ViewModels/WorksListOptions.swift:108` — main-actor+ `titleAscending` in a nonisolated context. Byte-identical to `main`'s line 86.+- `Asterism/Asterism/ViewModels/WorksListOptions.swift:342` — main-actor+ `name(for:)` in a nonisolated context. Byte-identical to `main`'s line 323.++The last two are in a file this feature did extend, which is why they were+checked against `main` line by line rather than waved through: the branch adds+82 lines to that file and neither warning site is among them.++The release build under `make test-performance-m4` (which is not piped through+`xcbeautify`, so nothing is filtered out of it) surfaces a further set of+warnings in `ConstellationKitTests/ConstellationSelectionRecipesTests.swift` and+`AsterismCoreTests/RepositoryReShareTests.swift` — main-actor isolation inside+`#expect` macro expansions, and one unused result. **Neither file is in this+branch's diff.** They are pre-existing and were previously hidden by+`xcbeautify`'s filtering on the debug path; they are recorded here rather than+left for the next person to rediscover.++## 5. Requirements this run does not cover++Two things the suites cannot see, both by decision rather than by omission:++- **Req 5.2's visual knock-down** (Q67). XCUITest cannot read opacity, so the+ journeys assert the Req 5.5 accessibility words that stand in for it. Whether+ an abandoned row actually *looks* stepped back is the owner's eye.+- **Req 5.1's glyph placement** (Q61). That the glyph sits after the type tag is+ a layout fact no unit test can see; the task 18 journey carries it, at default+ type and at accessibility5 sorted A to Z (Q68), and that journey is green.++Req 3.1's third action is a third matter: on iPhone it is the platform's dismiss+region rather than a drawn Cancel button (Q65), which is recorded in `design.md`+and left for the owner to rule on. `requirements.md` is deliberately unamended.++## 6. Owner's device checks++None of these is an agent's to run. Every device install is gated by `CLAUDE.md`'s+device-run rule — approval at the moment of running, every time — and the+existence of this list is not that approval.++**Before the first V10 install** (`prerequisites.md`):++- [ ] Export an **8/9 archive** from both `Personal` and `Development` on every+ device. This build refuses an 8/9 archive (Req 8.2, Q17) and a V9 build+ refuses a `"10"` store (Req 9.4), so rolling back means deleting the app,+ installing the V9 build and importing this archive. Taken **before** the+ install, it is the only rollback.+- [ ] Keep a V9 build installable (the pre-feature commit).+- [ ] Req 9.4's first clause is asserted, not tested: a V9 build meeting a `"10"`+ store refuses it by name through its own unknown-marker path. Confirm once+ on a device before relying on it as the rollback's other half.+- [ ] Confirm each device's marker reads `"9"`. The V10 plan has no stage for an+ `"8"` store and refuses it by name.++**During the rollout:**++- [ ] Run `Development` once on a signed-in simulator or device so+ `NSPersistentCloudKitContainer` publishes `workStatusRaw`,+ `readingStatusRaw` and `verdict` to the dev CloudKit container, before any+ second dev device syncs.+- [ ] Install `Personal` over the real library on one phone, confirm the first+ open completes the `"9"` → `"10"` conversion with every work reading+ ongoing / reading and no verdict, then update the **second device before+ either device opens the library again**. Adding columns is additive in+ CloudKit, but a V9 device syncing against V10 rows is not verified+ (Non-Goals, Q15).+- [ ] Confirm the share extension refuses with "Open Asterism to finish updating+ the library" while the marker is still `"9"`, and captures normally once it+ is `"10"` (Req 9.3).++**Eyes-only checks, on a device:**++- [ ] **Q67 — the abandoned knock-down.** Does an abandoned row actually read as+ stepped back? The suites cannot see opacity.+- [ ] **Q58 — the double dim.** A row that is *both* abandoned and wearing a+ removed type's `dimmedTypeTag` renders that pill at a quarter opacity.+ Accepted rather than clamped, because a clamp would need a third opacity+ the style guide does not have. Look at that pairing once and say whether it+ is acceptable. (The UI fixture deliberately keeps the two cases on+ different works, so this pairing is not on screen in the journeys.)+- [ ] **Q65 — the finished-reading dialog.** On iPhone iOS 26 draws it as an+ anchored popover with no Cancel button; the third outcome is tapping+ outside it. Either amend Req 3.1 and Req 10.2 to "three outcomes, the third+ being the platform's dismissal", or ask for a presentation that draws+ Cancel. `design.md` records the measurement; `requirements.md` is+ unamended, awaiting this.+- [ ] The Mac app's rendering of the two status capsules and the dialog. Nothing+ here launched it.
diff --git a/specs/work-and-reading-status/implementation.md b/specs/work-and-reading-status/implementation.mdnew file mode 100644index 0000000..e078bdc--- /dev/null+++ b/specs/work-and-reading-status/implementation.md@@ -0,0 +1,387 @@+# Implementation: Work and Reading Status++Transit: T-2306. Branch `T-2306/work-and-reading-status`, 25 commits over+`90d9d17` (`90d9d17..8624d22`), 148 files, +7,493 / −2,297, plus the+pre-push-review fixes still in the working tree. Written after that review.++The test evidence — what was run, the eight known performance issues, the three+pre-existing `make test-ui` failures and the owner's device checks — is in+[`verification-run.md`](verification-run.md), not here. The review rounds and+their rulings are in [`decision_log.md`](decision_log.md): Q42–Q69 are the+implementation and review quick decisions, and Decision 2 is the one entry still+`proposed`.++## Beginner Level++### What Changed++A work in the library now records two things it never did: whether the **author**+is still writing it (`ongoing`, `finished`, `hiatus`) and where the **reader**+stands with it (`reading`, `finished`, `abandoned`). Once the reader is done —+finished or abandoned — an optional free-text **verdict** goes with it: "how was+it?" if they finished, "why did you stop?" if they gave up.++You set both by hand on the work's page, in the same edit mode that already edits+the title and tags. Nothing is guessed from the website and a new chapter capture+never changes either value. Around that sit the visible consequences:++- **The work page** shows both statuses on its meta line (the `{n} notes ▲ ▼`+ strip) and the verdict as a paragraph above the notes.+- **The Works list** marks a row with a small glyph per non-default status, dims+ an abandoned row, and sinks every abandoned work to the bottom of its section.+- **Two new filters** — work status and reading status — sit beside the existing+ type, tag and site filters.+- **A rule**: "finished reading" only makes sense on a finished work. Choosing it+ on an ongoing work asks whether to mark the work finished too, use "abandoned"+ instead, or back out.+- **The database grew three columns**, so the app converts your library on first+ open, and the backup file format moved with it.++### Why It Matters++The library could tell you what you had captured but not what you had *done*+with it. A shelf of a hundred works with no way to see which are dead, which you+dropped, and what you thought of the ones you finished is an archive, not a+reading library.++The delicate part is the same one every schema change has here: converting a+stored library is a one-way door. A build that has converted your library cannot+hand it back to the previous build, so most of the care in this branch is in the+conversion being additive, checked before it is announced, and rollable back only+through a backup archive taken beforehand.++### Key Concepts++- **Schema version / lightweight migration** — the shape of the app's database,+ here **V9 to V10**, converted by SwiftData itself because the change is purely+ additive: the three columns simply appear, filled with their declared defaults.+ No code walks the rows.+- **Readiness marker** — a tiny file beside the database holding a generation+ number. It moves `"9"` → `"10"` only *after* the converted store validates, so+ a crash mid-conversion leaves `"9"` and the next open just tries again.+- **Tolerant read** — if a stored status spells something this build has no case+ for, it *reads* as the default rather than crashing. Writing is unaffected: an+ edit writes what the picker showed.+- **Group / carrier** — one work can be stored as several rows (same story on two+ sites or two devices); the "carrier" is the representative row. Every edit here+ writes to **every** row.+- **Variant / review card** — when two rows of a group disagree on something the+ reader authored, the app shows a review card rather than silently collapsing+ them. A non-default status and a non-empty verdict now count as authored+ content, so they get that protection.+- **Archive** — the backup file. Its version pair moved from **8/9 to 9/10**, and+ a pre-feature archive is refused by version.++---++## Intermediate Level++### Changes Overview++**Core (`Packages/AsterismCore`)**++| Area | Files |+|---|---|+| Enums | `DomainEnums.swift` — `WorkStatus`, `ReadingStatus` (+ `isDone`); `WorkType` deleted (Q42) |+| Columns and accessors | `Models.swift` — `workStatusRaw`, `readingStatusRaw`, `verdict` on `Work`, plus two `ToleratedEnum.read` accessors |+| Schema | `AsterismSchemaV9.swift` (frozen snapshot, was V8's role), new `AsterismSchemaV10.swift` + `AsterismV10MigrationPlan`; `AsterismSchemaV8.swift` deleted |+| Bootstrap | `LibraryRepository+Bootstrap.swift`, `+BootstrapState.swift` — markers `"9"` lagging / `"10"` published |+| Write chain | `Snapshots.swift`, `GroupOrdering.swift`, `RepositoryDrafts.swift`, `LibraryWrites.swift`, `LibraryRepository.swift` (`updateWork`, `normalizeVerdict`), `LibraryRepository+Redirect.swift`, `+Groups.swift`, `+ConfirmImport.swift` |+| Reconciliation and merge | `DuplicateReconciler.swift`, `DuplicateResolution.swift`, `LibraryRepository+DuplicateResolution.swift`, `WorkVariantUnion.swift`, `WorkMergePlanner.swift`, `ProjectionContract.swift` |+| Archive | `BackupV8*.swift` → `BackupV9Types/Codec/Exporter.swift`, `BackupArchiveProjection.swift`, `BackupImporter.swift`, golden `backup-9-10-golden.json` |++**App (`Asterism/Asterism`)** — `Views/WorkStatusPresentation.swift` (new, the one+table); `Views/WorkDetailView.swift` (two capsules, verdict card, meta-line+items, verdict paragraph, `finishedReadingDialog`);+`ViewModels/WorkDetailModel.swift` (three drafts, the transition methods,+`FinishedReadingPrompt`); `ViewModels/WorksListOptions.swift` (`abandonedLast`,+two filter dimensions, qualified labels); `Views/WorksView.swift` (row glyphs,+dimming, `WorksRowPresentation`); `Views/WorkMergeView.swift` (`destinationLabel`,+`recordedInNotes` captions); `Views/DuplicateResolutionView.swift`.++**Tests** — new suites `WorkStatusPresentationTests`,+`WorksFilterPresentationTests`, `WorksRowPresentationTests`,+`WorkSnapshotMembershipTests`, `MarkerGenerationTenTests`,+`V9RecordedStoreTests`/`Fixture`, `WorkDetailStatusUITests`, beside a dozen+extended ones.++### Implementation Approach++**Schema V10 over a frozen V9, one lightweight stage.** `Models.swift`'s ten live+classes moved from `extension AsterismSchemaV9` to `extension AsterismSchemaV10`;+the previous live bodies were copied into `AsterismSchemaV9.swift` as the frozen+snapshot, and `AsterismSchemaV8.swift` went with the stage that named it (Q18 —+every device confirmed at marker `"9"`). `AsterismV10MigrationPlan.stages` is a+single `.lightweight(fromVersion: V9, toVersion: V10)`; the three columns carry+property initialisers, which SwiftData turns into Core Data attribute defaults,+and those defaults are the whole conversion.++**A tolerant accessor over a raw column.** `Work.workStatus` / `.readingStatus`+are computed over `workStatusRaw` / `readingStatusRaw` through+`ToleratedEnum.read(_, default:)` (`Models.swift:389+`), the `titleProvenance`+pattern. An unknown spelling — an empty string included — reads as the default+(Reqs 1.3, 2.7), while `BackupArchiveProjection.requireRepresentableValues`+refuses to *export* the same value by name (Req 8.3). Reading is tolerant, the+wire is strict.++**Three fields ride every carrier, with a required/defaulted split.** Every type+that carries authored work content gained the three fields, and whether the+parameters have defaults was decided per type by what an omission costs:++| Carrier | Parameters | Why |+|---|---|---|+| `WorkMetadataDraft` | **required** (Q40) | `updateWork` writes all three to every row; an omitted one silently resets a reader's status |+| `WorkVariantSide` | **required** (Q52) | An omitted verdict drops out of the audit block and the row holding it is then deleted |+| `WorkEditBasis` | defaulted (Q47) | An omission can only *over*-refuse a redirected write — visible, never silent |+| `WorkMergeOutcome` | defaulted (Q69) | Describes the target after the fold; can only mis-display, never write |+| `WorkSnapshot`, `WorkAuthoredContent` | defaulted | Read-only carriers |++**One shared fold.** `WorkVariantUnion.fold(into:others:)` is the single content+merge behind both the merge planner and the duplicate-resolution survivor write.+It seeds `retained` with the chosen side's three values unconditionally; lists a+side's status in `discarded` only when it is non-default **and** differs (Q38);+and records a differing non-blank verdict in the audit block through+`WorkMergeAuditFormatter.block(sourceTitle:discardedWorkURLs:sourceVerdict:sourceNotes:)`,+adding `verdictRecorded` to the block's gate so a side differing by verdict alone+still produces one (Q41).++**One presentation table.** `WorkStatusPresentation` / `ReadingStatusPresentation`+hold `name`, `systemImage`, `hue`, `accessibilityLabel`, `controlIdentifier` and+`verdictPrompt` (Q36). Four surfaces read it — row glyph and label, filter menu+and pills, meta line, duplicate sheet (Q54) — so the spelling cannot drift. Every+label is dimension-qualified ("Work: Finished", "Reading: Finished") because both+enums have a value called `finished` (Q23).++**The abandoned-last partition** is two `filter` calls concatenated at the end of+`WorksSort.apply` (`WorksListOptions.swift:88`). Both that and `WorksView`'s+split-by-emptiness are stable, so "abandoned last within every section under+every sort, query and filter" (Req 5.3) follows from one line and no+section-aware code (Q31). `partition(by:)` is unstable and is deliberately unused.++**The transition table and the `presenting:` dialog.** The two capsules bind+through `setDraftWorkStatus`/`setDraftReadingStatus`+(`WorkDetailModel.swift:715, :730`) rather than raw writes, because the+transitions carry Decision 1: selecting `finished` reading on a non-finished work+raises `FinishedReadingPrompt` and leaves the draft alone; leaving `finished`+work reverts a `finished` reading to `reading` and sets `autoRevertedReading`;+returning to `finished` restores it (Q39). `finishedReadingCommitPrompt()`+(`:751`) runs first in `commitEditing()`, gated on the draft pair differing from+the stored pair (Q20, Req 3.5). The prompt travels on the presented value+(`presenting:`) and resolves through+`resolveFinishedReadingPrompt(_:choosing:) async`, with cancel as the separate+synchronous `cancelFinishedReadingPrompt()` — SwiftUI clears the presented value+before the tapped button's action, so `thenCommits` cannot be read back off the+model (Q37, Q62).++### Trade-offs++- **Retiring the V8 → V9 stage** (Q18) cuts the plan to one stage and deletes a+ frozen snapshot, at the cost that a marker-`"8"` library is now unopenable.+- **The archive generation renames wholesale** (`BackupV8*` → `BackupV9*`, Q34)+ rather than keeping a compatibility reader, so a pre-feature archive is refused+ by version (Q17). Cheaper to maintain, harsher on rollback.+- **Every existing `VariantID` changes once** (Q29), because the id hashes+ `orderComponents` and those gained three parts. The ids live in an in-memory+ ledger and in torn-write disclosures, so the cost is one re-derivation and+ possibly one re-presented sheet.+- **The finished-reading invariant is a UI rule, not a data guarantee**+ (Decision 1): two devices can each make a valid edit that together violate it,+ so every read path must tolerate a stored `finished`/`ongoing` pair.+- **The verdict is trimmed in exactly one place** (Q33), with import writing it+ verbatim (Q55) — `updateWork` stays the single normaliser at the cost of a+ hand-forged archive being able to store whitespace.++---++## Expert Level++### Technical Deep Dive++**The two-character marker.** `"10"` is the first readiness marker longer than one+character. Every comparison is string equality or set membership+(`extensionOpenableMarkerVersion`, `appOpenableMarkerVersions` in+`LibraryRepository+BootstrapState.swift:154, :164`), so nothing ordered or+character-indexed had to change. The real hazard was in the tests: five suites+used the literal `"10"` as their canonical *unrecognised* marker text, which+would have silently become the live generation. Task 1 moved them to `"99"`+before task 5 bumped the constants (Q28).++**Whitespace verdicts and the single trim site.** `updateWork` trims through+`LibraryRepository.normalizeVerdict` (`:1788`) beside `normalizeTags`, and+nothing else trims (Q33); import writes the archive's verdict verbatim (Q55).+That leaves one asymmetry — a whitespace-only verdict can exist on a row that+`updateWork` never touched — and the pre-push-review fixes close it at the read+sites rather than adding a second trim: `WorkDetailView`'s verdict paragraph+(`:399`), `DuplicateReconciler`'s propagation guard and `WorkVariantUnion.fold`+all now test `M2Unicode.isBlank`, so blank means absent everywhere the verdict is+shown, propagated or recorded.++**The escaped `Verdict:` line (Q53).** The audit block's structured region is+delimited by blank lines and a `--- Merged from:` header. A verdict is multi-line+reader text, so `WorkMergeAuditFormatter.block` runs it through the same+`escapedField` the header uses (`WorkMergePlanner.swift:29, :45`): an unescaped+`\n\n` would end the structured region early, and a reader who typed+`--- Merged from:` into a verdict would forge a block boundary.++**The reconciler's non-default guard and the cross-device window.**+`DuplicateReconciler.apply` writes a carrier's status to a sibling row only when+the carrier's value is **non-default** and differs (`DuplicateReconciler.swift:1299+`).+This is not tidiness: rows arrive from CloudKit in an arbitrary order, so a+carrier that has not yet received the reader's `abandoned` must never overwrite a+sibling that has. There is no `propagates` gate as the type arm has — that gate+exists only because a type may be `.removed`, which a closed three-value+vocabulary has no counterpart for. The working-tree fix also hoists the three+tolerant reads out of the per-row loop, so a torn group of *n* rows parses the+carrier's raw columns once rather than 2*n* times.++**The popover cancel (Decision 2).** `WorkDetailView.finishedReadingDialog`+declares three buttons on a `confirmationDialog`. Measured in the accessibility+tree during task 18, iOS 26 draws this dialog on iPhone as an anchored popover at+*every* type size, and a popover-presented confirmation dialog omits the declared+cancel: `work-detail-finished-cancel` exists on no iPhone presentation. The+declared button stays (macOS and regular width may draw it, and the delete dialog+carries the same declaration for the same reason), the journeys cancel through+`declineConfirmationDialog` and assert the drafts are unchanged, and+`requirements.md` is left unamended pending the owner's ruling.++**`declineConfirmationDialog`'s fallback tap (Q66).** At `accessibility5` the+popover spans 366×758 of an 874 pt window, so the helper's old fixed (0.5, 0.55)+fallback landed *inside* "Mark the work finished too" — the XXXL journey was+green while writing the opposite of what Req 3.2 says cancelling does. The helper+now computes a point in a margin the popover leaves, and only where the popover+covers the old default point, so every existing caller taps where it always did.++**The every-row write rule.** `updateWork`'s loop+(`LibraryRepository.swift:1222–1241`) writes all three fields to every row of the+group inside the same exclusive lock as the title, type, tags and notes; a+partial write would re-tear the group. Q19's corollary: what the picker showed is+what is written, so an unknown stored spelling is *replaced* by an unrelated edit+rather than preserved — the draft has no representation for a value the picker+cannot name.++**The archive's required wire fields (Q34).** `BackupV9Work.workStatus`,+`.readingStatus` and `.verdict` are non-optional and undefaulted on the wire+(`BackupV9Types.swift:247–252`), the two statuses typed as the enums exactly as+`titleProvenance` is. The only archive this build accepts is one it wrote+(`formatVersion = 9`, `schemaVersion = 10`, `:19–20`), so a decode default would+mask a malformed archive rather than serve a legitimate one.++### Architecture Impact++- `isBare` in `WorkAuthoredContent` now compares all three against their defaults.+ If it did not, every work would be non-bare, `redirectWork`'s+ `presentedContent.isBare` fast path would never fire, and silent duplicate+ healing would stop library-wide with nothing for the compiler to say.+- `orderComponents` gained three parts, which changes every `VariantID` once+ (Q29) and is the measured source of the 3–6% rise in the capture-projection and+ diagnosis labels (`verification-run.md` §4.1).+- The live stored shape is no longer a **subset** of the frozen one — V10 adds+ where every previous stage removed or added tables. `V9RecordedStoreFixture`'s+ create-seed-save-**release** ordering and `make test-core`'s `--no-parallel` are+ the only things holding SwiftData's global entity registry coherent+ (`AsterismSchemaV10.swift` doc comment, `docs/agent-notes/schema-migration.md`).+- `WorkType` is gone from `AsterismCore` (Q42) and listed in+ `FrozenLibraryPathTests.removedMachineryStaysRemoved` (`:577`) beside+ `AsterismSchemaV8` and `AsterismV9MigrationPlan`.++### Potential Issues++- **CloudKit publish of the three fields.** `NSPersistentCloudKitContainer`+ publishes new fields on first run against a container. `prerequisites.md`+ carries the step: run `Development` once on a signed-in device before any+ second dev device syncs. Nothing in the suites can check it.+- **A V9 device syncing against a V10 library is an explicit non-goal** (Q15,+ Non-Goals). Adding columns is additive in CloudKit, but it is unverified — hence+ the "update the second device before either opens the library again" step.+- **The double dim (Q58).** A row that is both abandoned and wearing a removed+ type's `dimmedTypeTag` renders that pill at roughly a quarter opacity, because+ Req 5.2's row-level knock-down multiplies with the pill's own. Accepted rather+ than clamped (a clamp needs a third opacity constant the style guide lacks), and+ the fixture keeps the two cases on different works, so no journey puts the+ pairing on screen. It needs one look on a device.+- **The pre-existing scale-test trio.** `make test-ui` reports three failures, all+ `M4ScaleRecentPerformanceUITests` (`verification-run.md` §3). They predate this+ branch and touch none of it, but a future runner should not read the non-zero+ exit as this feature's. Likewise the two main-actor warnings in+ `WorksListOptions.swift` (`:108`, `:342`) are byte-identical to `main`'s.++---++## Completeness Assessment++### Fully implemented++- **Reqs 1 and 2 (both statuses and the verdict)** — columns, tolerant+ accessors, edit-mode capsules, the verdict card with its status-dependent+ prompt; creation defaults on all four minting paths (`RepositoryWorksTests`,+ `RepositoryCaptureTests`, `BackupImportTransactionTests`).+- **Req 3 (the finished-reading rule)** except 3.1's "exactly three actions"+ wording — every row of the transition table and the commit gate live in+ `WorkDetailModel` and are covered by `WorkDetailModelTests`.+- **Req 4** — meta-line items, verdict paragraph, four distinct glyphs.+- **Req 5** except 5.2's *visual* knock-down — glyphs, dimming, the stable+ abandoned-last partition, the merge picker's rows, qualified labels.+- **Req 6** — two `allCases`-driven dimensions, no pruning, qualified pills and+ empty state.+- **Req 7** — every-row write, variant formation via `isBare`/`orderComponents`,+ carrier-wins survivor write, the shared fold, edit-basis comparison.+- **Req 8** — 9/10 generation, required wire fields, export refusal by name,+ re-recorded golden.+- **Req 9** — V10 plan, marker `"9"` → `"10"` published only after validation,+ extension gated on `"10"`; asserted by `V9RecordedStoreTests` and+ `MarkerGenerationTenTests`.+- **Reqs 10.1 and 10.3** — identifiers and labels on every new control;+ `make test-ui-ipad` green on the wide layout.++### Partially implemented++- **Req 3.1 and Req 10.2 — unmet as written, pending Decision 2.** The dialog+ declares three actions but iOS presents it as a popover on iPhone, which draws+ two buttons; the third outcome is the platform's dismiss region. The behaviour+ the requirement protects (a way out that changes nothing) holds and is asserted;+ the wording is the owner's to amend or overrule.+- **Req 5.2's visual knock-down** — implemented (`knockdownOpacity` on the row's+ `VStack`, `secondaryText` on the title) but not verifiable by test: XCUITest+ cannot read opacity, so the journeys assert the Req 5.5 words that stand in for+ it (Q67). Owner's eye required.+- **Req 9.4's first clause** — that a *pre-feature* build meets its own+ unknown-marker refusal on a `"10"` store is asserted from that build's code, not+ executed. This build's half (refusing an unknown marker by name) is tested.++### Missing++Nothing in the requirements is unimplemented. The declared non-goals — Stats,+markdown export, share sheet, Recent ordering, status history, per-site status,+a status sort option — are absent by decision (Q11, Q12).++### Potentially incomplete++- The **CloudKit publish** of `workStatusRaw`, `readingStatusRaw` and `verdict`+ is a step in `prerequisites.md` that no code or test can confirm. Until it runs,+ a second `Development` device's behaviour is unknown rather than known-good.+- **`WorkMergeOutcome`'s three target values are carried but rendered nowhere**+ (Q51, Q69). The design reserves them for a preview summary line that does not+ exist; they are dead contract surface until it does.++---++## Owner's next steps++1. **Rule on Decision 2.** Either amend Req 3.1 and Req 10.2 to "three outcomes,+ the third being the platform's dismissal", or ask for a presentation that draws+ Cancel on iPhone. The entry stays `proposed` until then, and+ `requirements.md` is deliberately unamended.+2. **The device steps in [`prerequisites.md`](prerequisites.md)**, expanded in+ `verification-run.md` §6 and all gated by `CLAUDE.md`'s device-run rule —+ approval at the moment of running, every time, and this list is not that+ approval: export an 8/9 archive from `Personal` and `Development` on every+ device **before** the first V10 install (it is the only rollback), keep a V9+ build installable, confirm each marker reads `"9"`, run `Development` once+ signed in so CloudKit publishes the three fields, then install `Personal`,+ confirm the `"9"` → `"10"` conversion leaves every work on ongoing / reading+ with no verdict, and update the second device before either opens the library+ again. Confirm the share extension refuses at `"9"` and captures at `"10"`.+3. **Three eyes-only checks** (`verification-run.md` §6): the abandoned+ knock-down (Q67), the double dim on a row that is abandoned *and* carries a+ removed type (Q58), and the Mac app's rendering of the two capsules and the+ dialog. Nothing here launched the Mac app.
iOS 26 presents the finished-reading confirmationDialog as an anchored popover on the phone at every type size; the declared Cancel is never drawn there. The code keeps the house pattern; requirements.md is untouched. Rule on the wording or ask for .alert.
A save from a screen loaded before another device changed a status writes the loaded values back (Q49): last write wins, as for every field. The edit basis is only consulted after a collapse.
The type tag lands at a quarter opacity (Q58). Accepted, not clamped; look once on device.
make test-ui carries the three M4ScaleRecentPerformanceUITests failures recorded by ipad-and-mac-layouts; reproduced on a clean tree, not this branch's.
Trimming stays in updateWork only (Q33, Q55). Both readers now use the blank test, so a whitespace verdict from a forged archive shows nothing and does not propagate.