asterism branch T-2076/configurable-work-types commits 23 files 169 touched lines +12,369 / -1,330 tests core 1,486 · unit 527 · UI 76 — all green

Pre-push review: T-2076/configurable-work-types

Work types become user-configurable data: a synced WorkTypeEntity in schema V6, works citing a type identity by UUID beside the kept typeRaw compatibility column, soft removal with restore, a settings screen, a picker, and a 5/6 backup format that round-trips the list. 23 commits, five phases, each phase design-reviewed before this pre-push pass.

At a glance

  • Derivation over storage: a work's type is derived from two columns by one function (Q27 precedence doubles as pre-feature-edit detection); a type identity is a read-time per-field fold over permanently-duplicated rows (Decision 10).
  • Old builds keep working: configured-typed works read as untyped to pre-feature builds via the kept typeRaw column; the precisely-known blind spots are recorded (Q17/Q21/Q28), not hidden.
  • Backup round-trips: new 5/6 format exports one folded record per identity and imports additively — id match keeps local spelling, cross-library name match becomes an alias row, V4 archives still import with scoped untype semantics (Q35).
  • Non-destructive removal: removed types are hidden, never deleted; works keep labels; re-adding the name restores the identity with works reattached.
  • One open release gate: first entity ever added under live CloudKit mirroring (Q54) — pre-feature mirror coexistence needs an approved device test before release; documented in decision log, verification-run.md, CHANGELOG, and specs overview.
  • 55 quick decisions and 13 full decision-log entries record every deviation; every phase was design-critic-reviewed before this pass.

Verdict

Ready to push

All four review agents returned no blockers. The single major finding (the work editor's picker paying the settings screen's full-Works usage scan on every open) and ten minors/nits were fixed in fd2de48; three findings were deliberately skipped with recorded reasons. Both test suites are green after the fixes with a proven-trustworthy no-new-warnings check. One release gate stays open by design: Q54's pre-feature-build CloudKit coexistence check, an approval-gated physical-device procedure that does not block this push or the merge.

Review findings

15 raised · 11 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What Changed

Asterism's work types used to be a fixed list baked into the app: novel, toon, article, or "other". This branch turns the list into data the user owns. A new Work Types screen in Settings lets you add, rename, and remove types; the work editor's picker offers whatever the list holds; the app seeds three defaults (novel, webtoon, article). Because the list lives in the library database it syncs through iCloud, and backups gained a new format (5/6) that carries it, so restores bring your types back.

Why It Matters

The vocabulary is now yours — a webtoon is finally called webtoon, and readers of manhua or doujinshi can have types for those. Removal is gentle: works that used a removed type keep showing its label, and re-adding the same name restores the type with its works still attached.

Key Concepts

  • Schema migration — the database shape is versioned; opening the app once moves it from version 5 to 6 (one new table, one new column).
  • Soft removal — removing marks a type hidden rather than deleting it; that is what makes labels survive and restore possible.
  • Coexistence — your other device may run the old app for a while; the old columns are kept and written so the old app keeps working, with a few precisely-known quirks recorded in the decision log.

Changes Overview

Five areas in dependency order: (1) Schema V6 + foundations — V5 frozen as a snapshot, new CloudKit-shaped WorkTypeEntity (defaulted/optional, no uniques, no relationships), Work.workTypeID: UUID?, a declared lightweight [V5,V6] stage. (2) Bootstrap — marker "6", extension opens only "6", per-UUID-guarded seeding with epoch timestamps (Q25). (3) Repository surfaces — every read resolves assignments through a per-operation WorkTypeDirectory (fetch once, thread down); every write goes through shared writers fanning out to all local rows with per-field timestamps. (4) Backup 5/6 — folded export, additive import merge reusing the reconciler's collision/election spellings, V4 archives via a shared ArchiveWorkRecord commit loop. (5) App UI — Sites-pattern settings screen with usage counts, em-dash blank picker row, one shared knock-down opacity for dimmed pills.

Implementation Approach

The load-bearing idea is derivation over storage. A work's type is derived by one function with the Q27 precedence rule (non-other typeRaw beats workTypeID) doubling as pre-feature-edit detection. A type identity is a read-time fold over duplicate rows — latest field timestamp wins, deterministic tiebreaks, merged absorbing, pristine rows never assert. Writers are value-guarded (Q48/Q49), making the reconciler a fixed point and settings writes idempotent.

Trade-offs

  • UUID column over relationship (Decision 8): avoids the Site.entries fan-out problem; costs tolerance of unresolved ids everywhere (they render unresolved and heal).
  • Declared migration stage over inference (Decision 11): explicit shipped conversion; retired the 4.0.0 fixtures (coverage reseeded at V5).
  • Legacy works not migrated (Decision 7): preserves pre-feature behaviour exactly; costs a permanent dual system (configured + legacy).
  • Shared wire records over clones (Decision 12): drift impossible; the freeze is enforced by the 4/4 tests, not duplicated source.

Technical Deep Dive

Coexistence is the hard problem; Q27 is the keystone. The updated build writes typeRaw = "other" with every configured assignment or untype (6.11), so any non-other raw observed later proves a pre-feature edit — derivation demotes the work to legacy and the old build's edit wins. The inexpressible cases are recorded, not hidden: pre-feature untypes of configured works are invisible (Q17), the old reconciler can propagate a legacy carrier's type (Q21), and retype-then-untype resurrects the earlier assignment (Q28).

Fold determinism rests on total tiebreak chains. Field election compares (timestamp, value, stateRaw, name, lowercased canonical UUID, createdAt); the canonical chase is total over chains, cycles (lowest UUID), dangling targets (self as merged), unknown ids (nil, stored-id fallback). Q40 records the one ambiguity the design text left. Seeded property tests pin order-independence and idempotence.

Import reuses the reconciler's spellings — the archive list folds against itself with WorkTypeReconciler.collisions/elected, and name-matching uses the same visibleIdentity helper settings uses, so import and settings cannot disagree. Timestamps are archive-level (Q33) except the 7.3 restore, which asserts the import clock. Q35 scopes V4 untype: only what a V4 archive could express.

Architecture Impact

The sixth entity threads every entity-kind choke point, each pinned by contract test; RecordResolutionOrder conformance is deliberately absent (same-UUID resolution belongs to the directory). WorkTypeEntity is the first entity added under live CloudKit mirroring (Q54, measured from history) — the one unclosed verification, deliberately blocking release, not merge. Freezing V6 at V7 will invalidate the 5.0.0-seeded fixtures the same way Decision 11's stage retired the 4.0.0 ones — pre-recorded in the schema-migration agent note.

Potential Issues

  • The fetch-once directory idiom is discipline, not enforcement — new read paths must keep it (the review verified all ~30 current call sites).
  • The BootstrapClassifierTests digest flake fired in 2 of 3 post-phase full runs (never isolated); recorded with a hardening suggestion (checkpoint/exclude the WAL before hashing).
  • Removed/merged type rows are retained forever (Q15) — growth bounded by deliberate user adds; the whole-table fold would deserve a look only at pathological scale.

Important changes — detailed

WorkTypeAssignment: derivation is the coexistence mechanism

WorkTypeAssignment.swift

Why it matters. The precedence rule (non-'other' typeRaw beats workTypeID) is how the updated build detects and honours pre-feature edits — every display, export, and reconciliation decision flows from this one function.

What to look at. WorkTypeAssignment.swift: assignment(of:), orderToken

Takeaway. When two builds must share columns, make the newer build's writes carry a sentinel (typeRaw = "other") so any other value is proof of a foreign edit. Derivation, not migration, is what lets both builds keep writing.
Rationale. Q27: the updated build always writes 'other' for configured assignments (6.11), so any other value proves a later pre-feature edit.

WorkTypeDirectory: read-time per-field fold with a total canonical chase

WorkTypeDirectory.swift

Why it matters. Duplicate rows of one identity are permanent (concurrent seeding, additive-only sync). This fold is the only thing standing between that and nondeterministic names/states across devices.

What to look at. WorkTypeDirectory.swift: elect, resolve, canonicalized

Takeaway. A per-field last-writer fold with deterministic total tiebreaks makes concurrent single-field edits commute — the same convergence CloudKit applies per-field, restated over duplicate rows. Make every chase total (chains, cycles, dangling) so reads never fail.
Rationale. Decision 10: a whole-row winner drops one side of every concurrent two-field edit, violating requirement 6.6; three independent design reviewers converged on the field-wise fold.

WorkTypeWrites: value-guarded fan-out writers

WorkTypeWrites.swift

Why it matters. Every mutation path (settings, reconciler, import, editor) writes through these; the fan-out to every local row is what makes the fold's outcome device-independent, and the value guard is what makes the reconciler a fixed point.

What to look at. WorkTypeWrites.swift: apply(_:to:), setName, setState

Takeaway. Give convergence machinery idempotent writers: guard on value change, stamp per-field timestamps, fan out to all rows of the identity. A no-op rename then writes nothing (Q49) — sync churn avoided for free.
Rationale. Decision 10's fan-out plus Q48/Q49: the guard makes the reconciler's second pass find nothing to write and spares CloudKit a dirty row on no-op edits.

Bootstrap: marker "6", split lagging states, guarded seeding

LibraryRepository+Bootstrap.swift

Why it matters. The update path for every real library runs through this file: V5 stores republish without a data pass, V4 stores still get the site pass, the extension fails closed during the window, and seeding cannot resurrect a removed default.

What to look at. LibraryRepository+Bootstrap.swift: runPassAndCertify(sitePass:publishMarker:), seedWorkTypes

Takeaway. Seeding guarded per-seed-UUID in any state (not completion-marker-gated) is retry-safe and removal-respecting by construction: a default inserts only if no row with its fixed UUID exists in any state.
Rationale. Q25/Q26/Q37: the V5→V6 conversion is a lightweight stage run by ModelContainer.init, so marker "5" needs republication only; one boolean cannot express the three certification states.

Backup import: one commit loop over both archive formats

BackupImportWorkTypes.swift

Why it matters. The importer accepts 5/6 without forking the commit path: V4 records conform to the same ArchiveWorkRecord protocol and carry Q35's scoped untype semantics in their conformance.

What to look at. BackupImportWorkTypes.swift (list merge); ArchiveWorkRecord.swift (shared record protocol)

Takeaway. The archive's type list folds against itself with the reconciler's own collisions/elected before merging, and name-matching reuses settings' visibleIdentity — one spelling of every convergence rule, shared across import, settings, and reconciliation.
Rationale. Decision 13: up-converting V4 payloads to V5 shape was rejected because a V4 untyped record is ambiguous (Q35) and would need the format re-consulted at commit time anyway; the protocol collects the economy without inventing columns the archive never held.

Q54: first entity addition under live CloudKit mirroring

verification-run.md

Why it matters. The one thing this branch cannot verify locally. No precedent shows a pre-feature build's mirror import tolerating an unknown record type plus a new CD_Work column — and both configurations mirror.

What to look at. specs/configurable-work-types/verification-run.md (Before release section); decision log Q54

Takeaway. When a risk cannot be closed locally, measure that it is real (git history: mirroring live 2026-08-01, schema untouched since), record it where a release check will find it, and gate it on the approval the check actually needs.
Rationale. Q54: blocks release, not merge — the check needs a pre-feature build and this build against one container, which is physical-device work requiring explicit approval at the time it runs.

Review fixes: the picker stops paying the settings screen's scan

WorkDetailModel.swift

Why it matters. The one major finding of this review: pickerOptions called workTypes(), which computes usage counts by fetching every Work row — per editor open — and discarded them. Now it calls the new uncounted workTypeOptions().

What to look at. LibraryRepository+WorkTypes.swift: workTypeOptions(); WorkDetailModel.swift: pickerOptions

Takeaway. When one read serves two surfaces with different needs, split the read rather than making the cheap consumer pay for the expensive one's extras — and give the protocol default a forwarding implementation so mocks don't churn.
Rationale. Efficiency review finding: the usage pass scales with the works table (thousands) on a UI path where the type directory (tens of rows) suffices; the counting rule stays with the settings screen that displays it.

Key decisions

The type list syncs as library data (Decision 1); works cite it by UUID column, not relationship (Decision 8). A WorkTypeEntity.works inverse would reproduce the known Site.entries fan-out problem; UUID citation matches how Entry cites rules. Costs: unresolved ids must be tolerated (Q16/Q24) — they render unresolved and heal when sync delivers the row.
Removal is non-destructive; entries are retained indefinitely (Decisions 2/3, Q15). Works keep labels, restore-by-readd keeps one identity per name (Q10), and the additive-only sync posture never deletes synced rows. Growth is bounded by deliberate user adds.
Legacy-typed works are not migrated onto seeded types (Decision 7). Pre-feature behaviour is preserved exactly; the permanent cost is a dual system (configured + legacy) in display, export, and reconciliation.
Per-field fold over duplicate rows (Decision 10). Whole-row winners drop one side of concurrent two-field edits (violates 6.6). Per-field timestamps plus fan-out writes make single-field edits commute across devices.
The declared V5→V6 stage retires the 4.0.0 fixtures (Decision 11). Measured: a declared stage refuses model versions the plan does not declare (NSCocoaErrorDomain 134504). The stage-less alternative preserved test-only coverage of a path no shipped code takes; rejected. Conversion coverage was rebuilt at V5, where the shipping path actually starts.
The 5/6 format shares unchanged wire records with 4/4 (Decision 12); the import plan is a two-case enum, not an up-conversion (Decision 13). Two definitions of one unchanged shape can only drift; the freeze rule is enforced by the 4/4 codec's tests. Up-converting V4 payloads was rejected because a V4 untyped record is ambiguous (Q35) and the conversion would put columns in the plan the archive never contained.
Settings mutations may throw the existing recordNotFound (Q55). Extends the design's error-shape sentence with an existing repo error rather than inventing a new shape or failing silently. Recorded during this review.
Election-rule duplication left standing (review skip). WorkTypeDirectory.elect and WorkTypeReconciler.elected both implement latest-field-timestamp-wins with different tiebreak chains over different types. The quality reviewer verified the differing tiebreaks cannot produce divergent values; a generic keypath-based unification would obscure more than it deduplicates. Skipped deliberately.

Review findings

SeverityAreaFindingResolution
majorWorkDetailModel pickerpickerOptions called workTypes(), whose usage-count pass fetches every Work row, buckets, sorts, and builds authored content — on every editor open — then discarded the counts.New workTypeOptions() repository read (directory fold only, display order, no usage pass); protocol default forwards to workTypes() so the mock compiles untouched.
minorLibraryRepository+ExportentryExportInput fetched the type directory twice in one locked read (once inside the fetchWorkGroup convenience, once for snapshot).Fetches once and uses the existing fetchWorkGroup(id:context:types:) overload.
minorBackupV5Codecsha256Hex, archive date coders, and the canonical JSON encoder settings were byte-for-byte copies of BackupV4Codec's private helpers — version-neutral machinery Decision 12's rationale says to share.Hoisted into BackupCanonicalJSON in BackupJSONCodecSupport.swift; both codecs call the shared spellings. The V4 date-coder names survive as forwarding shims because FrozenLibraryPathTests requires them declared.
minorBackupV5Exporter~85 lines of orchestration (filename generation, cleanup, scavenge) cloned from BackupV4Exporter.Version-neutral pieces extracted to ExportStaging (backupFilename(version:exportedAt:), backupFilenamePrefix, scavengeBackups(in:)); both exporters call them. Full pipeline genericization deliberately not attempted (distinct error enums; would touch tests).
minorWorkDetailModel comparatorpickerOrder was a byte-identical private copy of the repository's display comparator, plus a re-sort of already-sorted input.Comparator unified as public WorkTypeSnapshot.displayOrder used by both; the sort call stays because a picker test deliberately feeds out-of-order rows and asserts the model orders them — removing it would have required a test change, which this review forbids.
minorLibraryDiagnosticsworkTypeCollisions hand-rolled the collision grouping that WorkTypeReconciler.collisions(in:) states once (and which import already reuses).Now calls WorkTypeReconciler.collisions(in:).
minordocs/agent-notes/testing.mdThe load-bearing --no-parallel rationale was stale: it named deleted schemas (V3/V4) and claimed Site.entries exists only in V5, while the live schema is V6 and the V5 snapshot keeps the inverse.Section rewritten for the current V5-snapshot vs live-V6 reality; the --no-parallel conclusion is unchanged.
minordesign.md / decision logTwo bookkeeping gaps: a stale 'FrozenLibraryPathTests unchanged' sentence, and the recordNotFound error-shape deviation unrecorded.Design sentence amended; Q55 quick row added.
nitConstellationKit / WorkTypePresentationThe Q23 knock-down opacity 0.5 was declared in three places across two modules.One public ConstellationRecipes.knockdownOpacity constant, referenced from all three sites.
nitWorkTypesModelsThree inline whitespace trims where WorkTypeName.trimmed is the purpose-built spelling.Replaced at all three sites.
nitBackupV4/V5CodecIdentical referenceRecord bodies on both wire Work records.entryIDs added to the ArchiveWorkRecord protocol; one protocol-extension property replaces both copies.
minorWorkTypeDirectory / WorkTypeReconcilerTwo spellings of the per-field election rule with different tiebreak chains.Skipped: verified the chains cannot produce divergent values; the types and epoch/nil semantics differ deliberately, and a generic unification would obscure more than it saves.
nitLibraryRepository+BootstrapThree near-identical acting-case blocks differing only in the Q37 booleans.Skipped: the file's own header defends each case stating its own sequence (pre-existing convention); a helper starts paying off only if a fourth marker generation arrives.
nitBackupImportWorkTypesThe list merge re-folds the whole table per inserted row (O(n²) in type rows).Skipped: import-only, tens of rows, negligible real cost; noted for the future in the review record.
nitReq 8.8 coverageNo direct test that a capture-created work comes out untyped (holds by model defaults; the extension-never-seeds half is tested).Skipped per the no-test-modification constraint; listed under double-check for a future test pass.

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift Added +51 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swiftnew file mode 100644index 0000000..f8d3afe--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift@@ -0,0 +1,51 @@+import Foundation+import SwiftData++/// The runtime schema. Its body is `Models.swift`, which opens+/// `extension AsterismSchemaV6`.+///+/// V6 is V5 plus the two additions `configurable-work-types` needs: the+/// `WorkTypeEntity` table holding the configured list, and `Work.workTypeID`,+/// the plain UUID column a Work cites its type by (Decision 8 — no relationship,+/// so no CloudKit inverse and no fan-out). `Work.typeRaw` stays exactly where it+/// was: it is the compatibility surface pre-feature builds read and write+/// (Decision 4), not a column this feature repurposes.+///+/// Both additions are additive and CloudKit-legal by construction: every+/// property is defaulted or optional, nothing is unique, and `WorkTypeEntity`+/// declares no relationships at all.+public enum AsterismSchemaV6: VersionedSchema {+    public static let versionIdentifier = Schema.Version(6, 0, 0)++    public static var models: [any PersistentModel.Type] {+        [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,+         WorkTypeEntity.self]+    }+}++/// The migration plan: `[V5, V6]`, one lightweight stage.+///+/// It was `AsterismV5MigrationPlan` — one schema, no stages — after+/// `retire-migration-chain` collapsed the V3 → V4 → V5 chain. The V5 → V6 step+/// adds a column and a table and changes nothing that exists, so it is exactly+/// what `.lightweight` is for: `ModelContainer.init` runs the conversion, and no+/// data pass accompanies it (`docs/agent-notes/schema-migration.md`'s six-step+/// table).+///+/// `AsterismSchemaV5` survives as a frozen snapshot rather than being deleted+/// with the live classes: a staged migration needs its `from` version to be a+/// declared schema whose stored shape matches what installed stores actually+/// hold.+///+/// The stage is **not** `.custom`: a custom stage never fires between+/// structurally identical models, and it would also run inside the share+/// extension, which must never migrate.+public enum AsterismV6MigrationPlan: SchemaMigrationPlan {+    public static var schemas: [any VersionedSchema.Type] {+        [AsterismSchemaV5.self, AsterismSchemaV6.self]+    }++    public static var stages: [MigrationStage] {+        [.lightweight(fromVersion: AsterismSchemaV5.self, toVersion: AsterismSchemaV6.self)]+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkTypeAssignment.swift Added +72 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeAssignment.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeAssignment.swiftnew file mode 100644index 0000000..5629d9e--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeAssignment.swift@@ -0,0 +1,72 @@+import Foundation++/// What type a work has, derived from its two stored columns.+///+/// `Work` carries both the V6 `workTypeID` and the pre-feature `typeRaw` that+/// old builds read and write (Decision 4). Neither column is a work's type on+/// its own; this enum is, and `assignment(of:)` is the only place the columns+/// become one. Every subsystem that compares, orders, propagates or displays a+/// type takes an assignment.+///+/// `.legacy` and `.unrecognised` render identically — the stored name, dimmed —+/// but diverge in the reconciler: a legacy value keeps its pre-feature+/// propagation behaviour, an unrecognised one never propagates (Req 8.4).+public enum WorkTypeAssignment: Equatable, Hashable, Sendable {+    /// Untyped. No pill, no export label, unauthored in reconciliation.+    case none+    /// A `WorkTypeEntity`, by identity. The id may not resolve yet — an+    /// unresolved type is a rendered state, not an error (Req 8.6).+    case configured(UUID)+    /// One of the closed pre-feature values `novel` / `toon` / `article`,+    /// written by a build that predates this feature (Req 2.4). It keeps its+    /// pre-feature propagation behaviour everywhere.+    case legacy(String)+    /// Any other raw value: displayed verbatim, never propagated (Req 8.4).+    case unrecognised(String)++    /// The derivation, and the whole of it.+    ///+    /// **The order of the two checks is the Req 6.10 mechanism.** An updated+    /// build writes `typeRaw = "other"` whenever it assigns a configured type or+    /// untypes (Req 6.11), so a `typeRaw` holding anything else can only have+    /// been written by a pre-feature build *after* this build last wrote the+    /// work — that edit wins, and the work reads as legacy-typed (Q27).+    ///+    /// The accepted costs of expressing it this way are recorded as Q17 (an old+    /// build's untype of a configured-typed work is indistinguishable from no+    /// edit) and Q28 (an old build's retype followed by its untype resurfaces+    /// the earlier configured assignment).+    public static func assignment(typeRaw: String, workTypeID: UUID?) -> WorkTypeAssignment {+        guard typeRaw == WorkType.other.rawValue else {+            return WorkType(rawValue: typeRaw) != nil ? .legacy(typeRaw) : .unrecognised(typeRaw)+        }+        if let workTypeID { return .configured(workTypeID) }+        return .none+    }++    public static func assignment(of work: Work) -> WorkTypeAssignment {+        assignment(typeRaw: work.typeRaw, workTypeID: work.workTypeID)+    }++    /// The value duplicate ordering and variant selection key on, `nil` for+    /// untyped so it sorts as an absent field (`OrderComponent.absentableString`).+    ///+    /// Configured types contribute their **identity**, not their name, which is+    /// what makes a rename leave duplicate grouping and variant selection+    /// untouched (Req 4.2). Callers canonicalize the id through+    /// `WorkTypeDirectory` first, so two works pointing at either side of a+    /// merge order as one type (Req 6.2).+    ///+    /// The three prefixes keep the kinds from colliding: a legacy `novel` and a+    /// configured type spelled "novel" are two types everywhere this project+    /// compares them (Req 8.5), even though a pre-feature build reads both+    /// through one compatibility value.+    public var orderToken: String? {+        switch self {+        case .none: nil+        case .configured(let id): "c:" + id.uuidString.lowercased()+        case .legacy(let raw): "l:" + raw+        case .unrecognised(let raw): "u:" + raw+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkTypeDirectory.swift Added +285 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeDirectory.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeDirectory.swiftnew file mode 100644index 0000000..91d41ac--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeDirectory.swift@@ -0,0 +1,285 @@+import Foundation++/// The configured type list, folded and resolvable, as of one fetch.+///+/// Every read that displays or compares a type assignment fetches the+/// `WorkTypeEntity` table once per locked operation and builds one of these+/// (Decision 8). It is a value: pure over the rows it was given, with no context+/// and no faulting, so the subsystems that consume it — ordering, the+/// reconciler, export, the snapshot mapper — take a directory rather than a+/// store.+///+/// Two things happen here and nowhere else.+///+/// **The per-field fold** (Decision 10). Duplicate rows of one identity are+/// permanent: concurrent seeding creates them and the additive-only posture+/// never deletes them. Two devices' edits can therefore land on *different rows*+/// of the same UUID — a remove written to one, a rename to the other — and a+/// whole-row winner rule would drop one of them. Each field is elected+/// separately, from the row carrying that field's latest timestamp.+///+/// **The canonical chase** (Q31). A `merged` identity's `canonicalID` is+/// followed to its terminal entry, read-time, every time. Nothing rewrites a+/// pointer after the merge marking, so the chase has to be total: multi-hop+/// chains, cycles and targets that have not synced in all resolve to something.+public struct WorkTypeDirectory: Equatable, Sendable {++    /// The epoch sentinel the models default to, and the *pristine* marker the+    /// election rules turn on: a row whose field timestamp is epoch has never+    /// been touched by a user action, so it does not assert that field against+    /// a row that has (Decision 9).+    public static let epoch = Date(timeIntervalSince1970: 0)++    // MARK: - Input++    /// One stored row, lifted out of SwiftData.+    ///+    /// The fold works on values rather than models so it is testable without a+    /// store and cannot accidentally fault anything. `PersistentIdentifier` is+    /// deliberately absent: two devices assign different ones to the same+    /// logical row, so it can never be part of a converging rule.+    public struct Row: Equatable, Sendable {+        public var id: UUID+        public var name: String+        public var nameModifiedAt: Date+        public var stateRaw: String+        public var stateModifiedAt: Date+        public var canonicalID: UUID?+        public var createdAt: Date++        public init(+            id: UUID,+            name: String = "",+            nameModifiedAt: Date = WorkTypeDirectory.epoch,+            stateRaw: String = WorkTypeState.active.rawValue,+            stateModifiedAt: Date = WorkTypeDirectory.epoch,+            canonicalID: UUID? = nil,+            createdAt: Date = WorkTypeDirectory.epoch+        ) {+            self.id = id+            self.name = name+            self.nameModifiedAt = nameModifiedAt+            self.stateRaw = stateRaw+            self.stateModifiedAt = stateModifiedAt+            self.canonicalID = canonicalID+            self.createdAt = createdAt+        }++        public init(_ entity: WorkTypeEntity) {+            self.init(+                id: entity.id,+                name: entity.name,+                nameModifiedAt: entity.nameModifiedAt,+                stateRaw: entity.stateRaw,+                stateModifiedAt: entity.stateModifiedAt,+                canonicalID: entity.canonicalID,+                createdAt: entity.createdAt)+        }+    }++    // MARK: - Output++    /// One folded identity: the fields as the fold elected them, plus the+    /// normalized name every comparison uses.+    public struct Identity: Equatable, Sendable {+        public let id: UUID+        /// The stored spelling of the electing row.+        public let name: String+        /// Computed here, never stored (Q30) — a stored copy could diverge from+        /// `name` under CloudKit's per-field merge.+        public let normalizedName: String+        public let state: WorkTypeState+        /// The merge target, when the identity is `merged`.+        public let canonicalID: UUID?+        /// The **minimum** over the identity's rows: a duplicate row created+        /// later does not make the identity younger, and survivor election+        /// (earliest `createdAt` wins) has to agree on every device.+        public let createdAt: Date+        public let nameModifiedAt: Date+        public let stateModifiedAt: Date++        public var modifiedAt: Date { max(nameModifiedAt, stateModifiedAt) }++        /// No field has ever been asserted: a seed nobody has touched. Such an+        /// identity loses every content tiebreak to a user-touched one, which is+        /// what keeps an emptied list empty when a reinstalled device seeds+        /// before sync delivers the removed rows (Decision 9).+        public var isPristine: Bool {+            nameModifiedAt == WorkTypeDirectory.epoch+                && stateModifiedAt == WorkTypeDirectory.epoch+        }+    }++    /// What a stored `workTypeID` resolves to. `nil` from `resolve(_:)` is the+    /// unresolved state — the type row has not arrived — which is rendered, not+    /// an error (Req 8.6).+    public struct Resolution: Equatable, Sendable {+        public let canonicalID: UUID+        public let name: String+        public let state: WorkTypeState+    }++    // MARK: - Construction++    private let folded: [UUID: Identity]++    public init(rows: [Row]) {+        var grouped: [UUID: [Row]] = [:]+        for row in rows { grouped[row.id, default: []].append(row) }+        folded = grouped.mapValues(Self.fold)+    }++    public init(entities: [WorkTypeEntity]) {+        self.init(rows: entities.map(Row.init))+    }++    public static let empty = WorkTypeDirectory(rows: [])++    // MARK: - Reading++    /// Every folded identity, ordered by identifier so callers that iterate get+    /// the same order on every device. Merged identities are included: they are+    /// what the chase walks. Surfaces that list types filter them out.+    public var identities: [Identity] {+        folded.values.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }+    }++    public subscript(id: UUID) -> Identity? { folded[id] }++    public var isEmpty: Bool { folded.isEmpty }++    /// Follows `id` to the entry that answers for it.+    ///+    /// Total by construction, because every degenerate shape a synced store can+    /// hold has an answer here:+    ///+    /// - a chain of merges resolves to its endpoint;+    /// - a cycle resolves to its lowest identifier, so every device picks the+    ///   same member;+    /// - a `canonicalID` naming a row that has not arrived leaves the chain+    ///   where it stands, so the merged entry answers for itself and renders as+    ///   removed until the target syncs in;+    /// - an id no row carries returns `nil`: unresolved, and the caller falls+    ///   back to the stored id until the entry arrives.+    public func resolve(_ id: UUID) -> Resolution? {+        guard var current = folded[id] else { return nil }+        var chain: [UUID] = [current.id]+        while current.state == .merged, let target = current.canonicalID {+            if let cycleStart = chain.firstIndex(of: target) {+                let lowest = chain[cycleStart...].min {+                    $0.uuidString.lowercased() < $1.uuidString.lowercased()+                }+                if let lowest, let entry = folded[lowest] { current = entry }+                break+            }+            guard let next = folded[target] else { break }+            chain.append(target)+            current = next+        }+        return Resolution(canonicalID: current.id, name: current.name, state: current.state)+    }++    /// The identifier every comparison keys on: after a same-name merge, works+    /// pointing at the loser and works pointing at the survivor are one type+    /// (Req 6.2, 8.5). An unresolved id falls back to itself and re-derives when+    /// the entry arrives.+    public func canonicalID(of id: UUID) -> UUID {+        resolve(id)?.canonicalID ?? id+    }++    // MARK: - The fold++    private static func fold(_ rows: [Row]) -> Identity {+        let id = rows[0].id+        let createdAt = rows.map(\.createdAt).min() ?? epoch++        let nameRow = elect(rows, timestamp: \.nameModifiedAt, value: \.name)+        let name = nameRow.name+        // The elected row's own timestamp, which is the latest any row carries:+        // election takes the maximum, and falls back to the epoch rows only when+        // every row is at epoch.+        let nameModifiedAt = nameRow.nameModifiedAt+        let stateModifiedAt = rows.map(\.stateModifiedAt).max() ?? epoch++        // `merged` is absorbing: one row marked merged makes the identity+        // merged, whatever the other rows say and whatever their timestamps are.+        // Terminality is what Req 6.3 rests on — a non-surviving entry must+        // never independently reappear — so it outranks the pristine rule too.+        let mergedRows = rows.filter { $0.stateRaw == WorkTypeState.merged.rawValue }+        if !mergedRows.isEmpty {+            let targets = mergedRows.filter { $0.canonicalID != nil }+            let target = targets.min { lhs, rhs in+                if lhs.stateModifiedAt != rhs.stateModifiedAt {+                    return lhs.stateModifiedAt > rhs.stateModifiedAt+                }+                return lhs.canonicalID!.uuidString.lowercased()+                    < rhs.canonicalID!.uuidString.lowercased()+            }+            return Identity(+                id: id,+                name: name,+                normalizedName: WorkTypeName.normalize(name),+                state: .merged,+                canonicalID: target?.canonicalID,+                createdAt: createdAt,+                nameModifiedAt: nameModifiedAt,+                stateModifiedAt: stateModifiedAt)+        }++        let stateRow = elect(rows, timestamp: \.stateModifiedAt, value: \.stateRaw)+        return Identity(+            id: id,+            name: name,+            normalizedName: WorkTypeName.normalize(name),+            state: WorkTypeState(rawValue: stateRow.stateRaw) ?? .active,+            canonicalID: nil,+            createdAt: createdAt,+            nameModifiedAt: nameModifiedAt,+            stateModifiedAt: stateModifiedAt)+    }++    /// Elects the row a single field comes from: the latest timestamp for that+    /// field wins, ties break on the field's own value and then on the rest of+    /// the row, so two devices holding the same rows elect the same one.+    ///+    /// Rows whose timestamp for this field is still epoch do not stand at all —+    /// unless none of them has ever been touched, in which case they are all+    /// there is and the value tiebreak decides.+    private static func elect(+        _ rows: [Row], timestamp: KeyPath<Row, Date>, value: KeyPath<Row, String>+    ) -> Row {+        let touched = rows.filter { $0[keyPath: timestamp] != epoch }+        let candidates = touched.isEmpty ? rows : touched+        return candidates.max {+            ElectionKey($0, timestamp: timestamp, value: value)+                < ElectionKey($1, timestamp: timestamp, value: value)+        } ?? rows[0]+    }++    private struct ElectionKey: Comparable {+        let timestamp: Date+        let value: String+        let stateRaw: String+        let name: String+        let canonical: String+        let createdAt: Date++        init(_ row: Row, timestamp: KeyPath<Row, Date>, value: KeyPath<Row, String>) {+            self.timestamp = row[keyPath: timestamp]+            self.value = row[keyPath: value]+            stateRaw = row.stateRaw+            name = row.name+            canonical = row.canonicalID?.uuidString.lowercased() ?? ""+            createdAt = row.createdAt+        }++        static func < (lhs: Self, rhs: Self) -> Bool {+            if lhs.timestamp != rhs.timestamp { return lhs.timestamp < rhs.timestamp }+            if lhs.value != rhs.value { return lhs.value < rhs.value }+            if lhs.stateRaw != rhs.stateRaw { return lhs.stateRaw < rhs.stateRaw }+            if lhs.name != rhs.name { return lhs.name < rhs.name }+            if lhs.canonical != rhs.canonical { return lhs.canonical < rhs.canonical }+            return lhs.createdAt < rhs.createdAt+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkTypeWrites.swift Added +107 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeWrites.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeWrites.swiftnew file mode 100644index 0000000..6e2667b--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeWrites.swift@@ -0,0 +1,107 @@+import Foundation++/// The two shared writers: one for a work's assignment, one for a type entry's+/// fields.+///+/// Both exist because the same write has to happen the same way from several+/// callers. `apply(_:to:)` is the [6.11](../../../../specs/configurable-work-types/requirements.md#6.11)+/// contract — it is what keeps the compatibility column readable by pre-feature+/// builds — and it is reached from the editor commit, the reconciler's carrier+/// propagation, the resolution sheet and the import appliers. The entry writers+/// are the per-field fold's other half (Decision 10): a field written to one row+/// of an identity and not the others folds correctly only by accident, so every+/// mutation fans out across every local row.+public enum WorkTypeWriter {++    // MARK: - Work side++    /// Writes an assignment onto one Work row, per the design's write table:+    ///+    /// | assignment | `workTypeID` | `typeRaw` |+    /// |---|---|---|+    /// | `.configured(X)` | X | `"other"` |+    /// | `.none` | nil | `"other"` |+    /// | `.legacy(raw)` / `.unrecognised(raw)` | nil | `raw` |+    ///+    /// The `"other"` in the first two rows is the whole of Req 6.11 and the+    /// mechanism behind Req 6.10: a pre-feature build reads a configured-typed+    /// work as untyped, and *any* other value in that column can only have been+    /// written by such a build afterwards (Q27).+    ///+    /// The third row is "save the carried legacy type unchanged". `workTypeID`+    /// is cleared rather than left alone: the assignment being written is the+    /// legacy one, and leaving a stale id behind is what Q28 describes.+    ///+    /// - Returns: whether the row's columns actually changed, so value-guarded+    ///   callers (the reconciler) can keep their write counts honest.+    @discardableResult+    public static func apply(_ assignment: WorkTypeAssignment, to work: Work) -> Bool {+        let (typeRaw, workTypeID): (String, UUID?) = switch assignment {+        case .none: (WorkType.other.rawValue, nil)+        case .configured(let id): (WorkType.other.rawValue, id)+        case .legacy(let raw), .unrecognised(let raw): (raw, nil)+        }+        guard work.typeRaw != typeRaw || work.workTypeID != workTypeID else { return false }+        work.typeRaw = typeRaw+        work.workTypeID = workTypeID+        return true+    }++    // MARK: - Type-entry side++    /// Renames every local row of one identity, stamping the name's own field+    /// timestamp.+    ///+    /// The fan-out is what makes the fold's outcome independent of which subset+    /// of an identity's rows each device happens to hold (Decision 10): a device+    /// that later receives a row this write never saw still folds to the same+    /// name, because the rows it *did* see all carry the new one.+    ///+    /// Value-guarded, and the guard covers the timestamp: a settings rename+    /// always writes (its stamp is the clock's), while the reconciler's copy of+    /// an elected value writes once and dirties nothing on the pass after.+    ///+    /// - Returns: how many rows changed.+    @discardableResult+    public static func setName(+        _ name: String, on rows: [WorkTypeEntity], at timestamp: Date+    ) -> Int {+        var changed = 0+        for row in rows {+            let modifiedAt = max(timestamp, row.stateModifiedAt)+            guard row.name != name || row.nameModifiedAt != timestamp+                || row.modifiedAt != modifiedAt+            else { continue }+            row.name = name+            row.nameModifiedAt = timestamp+            row.modifiedAt = modifiedAt+            changed += 1+        }+        return changed+    }++    /// The state counterpart. `canonicalID` travels with the state because the+    /// merge marking is one fact: an entry is `merged` *into* something, and a+    /// row carrying one without the other is a shape the chase would have to+    /// guess about.+    @discardableResult+    public static func setState(+        _ state: WorkTypeState, canonicalID: UUID? = nil,+        on rows: [WorkTypeEntity], at timestamp: Date+    ) -> Int {+        var changed = 0+        for row in rows {+            let modifiedAt = max(row.nameModifiedAt, timestamp)+            guard row.stateRaw != state.rawValue || row.stateModifiedAt != timestamp+                || (canonicalID != nil && row.canonicalID != canonicalID)+                || row.modifiedAt != modifiedAt+            else { continue }+            row.state = state+            if let canonicalID { row.canonicalID = canonicalID }+            row.stateModifiedAt = timestamp+            row.modifiedAt = modifiedAt+            changed += 1+        }+        return changed+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkTypeReconciler.swift Added +181 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeReconciler.swiftnew file mode 100644index 0000000..e2de472--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeReconciler.swift@@ -0,0 +1,181 @@+import Foundation+import SwiftData++/// What the type-convergence phase did.+///+/// Two counts rather than one because they answer different questions: how many+/// entries stopped being separate entries, and how many rows this pass actually+/// wrote. A converged library writes nothing, so `isEmpty` is what the callers'+/// refresh gates on.+public struct WorkTypeReconciliationOutcome: Equatable, Sendable {+    /// Identities marked `merged` into a survivor by this pass (Req 6.2).+    public var mergedIdentities = 0+    /// Rows written: the survivor's spelling and state, plus the losers'+    /// merge markings.+    public var writtenRows = 0++    public init() {}++    public var isEmpty: Bool { mergedIdentities == 0 && writtenRows == 0 }++    mutating func formUnion(_ other: Self) {+        mergedIdentities += other.mergedIdentities+        writtenRows += other.writtenRows+    }+}++/// Convergence for the type list: at most one visible entry per normalized name,+/// on every device, whatever order the rows arrived in (Reqs 6.2, 6.3).+///+/// **It runs on every pass, at every tier.** Arrivals are exactly when colliding+/// rows land, and the table is tens of rows — gating it on the cached tolerance+/// scan would reproduce the Q58 lesson, where a gated pass reading a stale scan+/// missed a hydration's final batch. Its work list is derived here, inside the+/// locked context, beside the Site work list.+///+/// Everything else about a type identity converges without this phase. Duplicate+/// rows of one UUID fold per field in `WorkTypeDirectory` (Decision 10), which is+/// why a remove written on one device and a rename written on another need no+/// case here at all. What is left is the genuinely cross-identity problem: two+/// *different* UUIDs that ended up spelled the same.+///+/// **Nothing here reads the clock.** Every value written is copied from the row+/// that elected it, with that row's own field timestamp, so two devices holding+/// the same synced content write byte-identical rows and the pass after this one+/// writes nothing.+enum WorkTypeReconciler {++    static func run(+        context: ModelContext, saveStrategy: any RepositorySaveStrategy+    ) throws -> WorkTypeReconciliationOutcome {+        let rows = try context.fetch(FetchDescriptor<WorkTypeEntity>())+        var outcome = WorkTypeReconciliationOutcome()+        guard !rows.isEmpty else { return outcome }++        let directory = WorkTypeDirectory(entities: rows)+        var rowsByID: [UUID: [WorkTypeEntity]] = [:]+        for row in rows { rowsByID[row.id, default: []].append(row) }++        for collision in collisions(in: directory) {+            outcome.formUnion(merge(collision, rowsByID: rowsByID))+        }+        guard !outcome.isEmpty else { return outcome }+        do { try saveStrategy.save(context) }+        catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "converging the work-type list", reason: String(describing: error))+        }+        return outcome+    }++    // MARK: - The work list++    /// Sets of two or more **visible** identities sharing a normalized name.+    ///+    /// Merged identities are excluded: they already answer for something else,+    /// and re-merging them would rewrite pointers the chase is meant to follow+    /// (Q31). Each set is returned in survivor order, so `first` is the survivor.+    ///+    /// Pure over a directory, and deliberately reachable from the backup import,+    /// which folds an *archive's* list against itself by exactly these rules+    /// (Req 7.3). Two spellings of "which entries collide, and which one wins"+    /// would be two chances for a device and an archive to disagree.+    internal static func collisions(+        in directory: WorkTypeDirectory+    ) -> [[WorkTypeDirectory.Identity]] {+        var byName: [String: [WorkTypeDirectory.Identity]] = [:]+        for identity in directory.identities where identity.state != .merged {+            byName[identity.normalizedName, default: []].append(identity)+        }+        // Sorted by name so the pass processes collisions in the same order on+        // every device — the writes are independent, but a deterministic order+        // makes a partially-committed pass reproducible.+        return byName+            .filter { $0.value.count > 1 }+            .sorted { $0.key < $1.key }+            .map { inSurvivorOrder($0.value) }+    }++    /// The Definitions' survivor rule, over identities: earliest `createdAt`,+    /// lowercased application UUID as the tie-break. The same+    /// `survivorComponents` ordering a duplicate set collapses under, so the two+    /// mechanisms cannot disagree about what "the earliest" means.+    private static func inSurvivorOrder(+        _ identities: [WorkTypeDirectory.Identity]+    ) -> [WorkTypeDirectory.Identity] {+        let byID = Dictionary(identities.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })+        return GroupOrdering.sortedSurvivorCandidates(+            identities.map { SurvivorCandidate(id: $0.id, timestamp: $0.createdAt) }+        ).compactMap { byID[$0.id] }+    }++    // MARK: - The merge++    private static func merge(+        _ colliding: [WorkTypeDirectory.Identity], rowsByID: [UUID: [WorkTypeEntity]]+    ) -> WorkTypeReconciliationOutcome {+        var outcome = WorkTypeReconciliationOutcome()+        guard let survivor = colliding.first else { return outcome }++        // The survivor takes the spelling and the state of the latest+        // **user-touched** identity, each elected separately — the same per-field+        // rule the fold uses within an identity, applied across them.+        //+        // This is where Req 6.4 comes from without a case of its own. A+        // deliberate add colliding with a removed entry the adding device had+        // not seen is user-touched and later, so the merged entry lands active;+        // a *seeding* collision is pristine on the seed's side, so the removal+        // stands and the entry stays removed (Req 2.5).+        let nameSource = elected(colliding, timestamp: \.nameModifiedAt, value: \.name)+            ?? survivor+        let stateSource = elected(+            colliding, timestamp: \.stateModifiedAt, value: \.state.rawValue) ?? survivor++        let survivorRows = rowsByID[survivor.id] ?? []+        outcome.writtenRows += WorkTypeWriter.setName(+            nameSource.name, on: survivorRows, at: nameSource.nameModifiedAt)+        outcome.writtenRows += WorkTypeWriter.setState(+            stateSource.state, on: survivorRows, at: stateSource.stateModifiedAt)++        for loser in colliding.dropFirst() {+            // The loser's **own** latest folded `stateModifiedAt`, never the+            // clock: `merged` is absorbing in the fold, so the timestamp is+            // inert — copying it is what keeps converged rows byte-identical on+            // every device given the same content.+            let written = WorkTypeWriter.setState(+                .merged, canonicalID: survivor.id,+                on: rowsByID[loser.id] ?? [], at: loser.stateModifiedAt)+            guard written > 0 else { continue }+            outcome.writtenRows += written+            outcome.mergedIdentities += 1+        }+        return outcome+    }++    /// The identity a single field is taken from: the latest timestamp for that+    /// field wins, with `(timestamp, value, id)` as the deterministic order.+    ///+    /// `nil` where **every** colliding identity is pristine — a set of untouched+    /// seeds — in which case the survivor keeps its own spelling and state+    /// rather than adopting a peer's for no reason (Decision 9).+    ///+    /// Internal for the same reason `collisions` is: the import elects an+    /// archive's colliding entries by this rule, not by a second copy of it.+    internal static func elected(+        _ identities: [WorkTypeDirectory.Identity],+        timestamp: KeyPath<WorkTypeDirectory.Identity, Date>,+        value: KeyPath<WorkTypeDirectory.Identity, String>+    ) -> WorkTypeDirectory.Identity? {+        identities+            .filter { $0[keyPath: timestamp] != WorkTypeDirectory.epoch }+            .max { lhs, rhs in+                if lhs[keyPath: timestamp] != rhs[keyPath: timestamp] {+                    return lhs[keyPath: timestamp] < rhs[keyPath: timestamp]+                }+                if lhs[keyPath: value] != rhs[keyPath: value] {+                    return lhs[keyPath: value] < rhs[keyPath: value]+                }+                return lhs.id.uuidString.lowercased() < rhs.id.uuidString.lowercased()+            }+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkTypeSeeding.swift Added +73 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkTypeSeeding.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeSeeding.swiftnew file mode 100644index 0000000..c734991--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkTypeSeeding.swift@@ -0,0 +1,73 @@+import Foundation+import SwiftData++/// The default work-type list, minted into a library by the app and only by the+/// app ([2.1](../../../../specs/configurable-work-types/requirements.md#2.1),+/// [2.5](../../../../specs/configurable-work-types/requirements.md#2.5)).+///+/// **The guard is per seed, on the identifier, in any state** (Q25). A default+/// is inserted only where no row carrying its fixed UUID exists at all — not+/// where the table is empty, and not where a completion record is absent. That+/// one rule answers every case the requirements raise:+///+/// - each absent default is added, which is [2.1](../../../../specs/configurable-work-types/requirements.md#2.1) read literally;+/// - a removed default is never resurrected, because its rows still exist;+/// - a list the user emptied stays empty across relaunch, reinstall on the same+///   account, and devices joining later;+/// - a type the user added in the first session cannot suppress the defaults,+///   which a "seed only into an empty table" guard would let it do.+///+/// There is no completion record to write, lose or diverge from, so the pass is+/// retry-safe by construction and runs on every app open.+///+/// **Fixed identifiers and epoch timestamps** (Decision 9). Two devices seeding+/// before they have synced produce duplicate rows of *one identity* — the case+/// `WorkTypeDirectory`'s fold already solves — rather than two identities that+/// would need a name merge. Epoch timestamps make a pristine seed lose every+/// content tiebreak to any user-touched row, which is what keeps an emptied list+/// empty when a reinstalled device seeds before sync delivers the removed rows.+public enum WorkTypeSeeding {++    /// One default: the frozen identifier it is always created with, and the+    /// spelling it starts life holding.+    public struct Seed: Equatable, Sendable {+        public let id: UUID+        public let name: String+    }++    /// **Frozen persisted state.** These identifiers are written into installed+    /// libraries and synced; changing one would make every device that had+    /// already seeded disagree with every device that had not, and re-mint a+    /// default the user removed. The spellings are Q3's — the legacy value+    /// `toon` is deliberately *not* among them, because `webtoon` is the name+    /// the list is seeded with while `toon` stays a legacy value on works.+    public static let seeds: [Seed] = [+        Seed(id: UUID(uuidString: "D0000001-0000-4000-8000-000000000001")!, name: "novel"),+        Seed(id: UUID(uuidString: "D0000002-0000-4000-8000-000000000002")!, name: "webtoon"),+        Seed(id: UUID(uuidString: "D0000003-0000-4000-8000-000000000003")!, name: "article"),+    ]++    /// Inserts every default the library holds no row for, in one save.+    ///+    /// - Returns: the identifiers actually inserted, empty where the library+    ///   already accounted for all three.+    @discardableResult+    public static func run(context: ModelContext) throws -> [UUID] {+        // The whole table, which is tens of rows by construction — the same+        // fetch `WorkTypeDirectory` rides on. Duplicate rows of one identity are+        // a normal permanent state, so this is a membership question about+        // identifiers and never a count.+        let present = Set(try context.fetch(FetchDescriptor<WorkTypeEntity>()).map(\.id))+        let missing = seeds.filter { !present.contains($0.id) }+        guard !missing.isEmpty else { return [] }++        for seed in missing {+            // Every timestamp defaults to the epoch sentinel, which is what+            // makes the row pristine: it asserts neither its spelling nor its+            // state against a row a user has touched.+            context.insert(WorkTypeEntity(id: seed.id, name: seed.name))+        }+        try context.save()+        return missing.map(\.id)+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkTypes.swift Added +278 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkTypes.swiftnew file mode 100644index 0000000..de69d8d--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkTypes.swift@@ -0,0 +1,278 @@+import Foundation+import SwiftData++// The work-types settings surface (Requirement 1, and Reqs 5.1/5.3/5.4).+//+// Four operations, all of them writes to the *list* and never to the works. That+// is Decision 2 and Decision 3 in one sentence: a work cites a type identity, so+// a rename reaches every work using it without touching one of them, and a+// removal takes an entry out of the picker without editing anything at all.+//+// Every mutation goes through `WorkTypeWriter`, which fans the changed field and+// its own timestamp across every local row of the identity (Decision 10), and+// every read goes through `WorkTypeDirectory`, which folds those rows back into+// one entry. Nothing here consults `state` on a single row.++/// One list entry as the settings screen sees it.+public struct WorkTypeSnapshot: Equatable, Sendable, Identifiable {+    public let id: UUID+    /// The stored spelling. Ordering is `localizedStandardCompare` over this,+    /// matching `sites()`.+    public let name: String+    /// `active` or `removed`. A `merged` entry is never surfaced — it is not a+    /// type any more, it is a redirection (Req 6.3).+    public let state: WorkTypeState+    /// User-visible works whose type resolves to this identity (Req 1.5): one+    /// per logical record, not per duplicate row, and never a legacy-typed work+    /// that merely shares the spelling (Q14, Decision 7).+    public let usageCount: Int++    public init(id: UUID, name: String, state: WorkTypeState, usageCount: Int) {+        self.id = id+        self.name = name+        self.state = state+        self.usageCount = usageCount+    }++    /// Display order: alphabetical by the reader's locale, identifier as the+    /// tie-break so a list does not reorder itself between reads (Q7, Q11).+    ///+    /// Public, and stated once, because the settings list is not the only thing+    /// that orders these rows: the editor's picker orders its own options by it+    /// too, and a reader seeing one order in settings and another in the picker+    /// would be looking at a bug.+    public static func displayOrder(_ left: WorkTypeSnapshot, _ right: WorkTypeSnapshot) -> Bool {+        let byName = left.name.localizedStandardCompare(right.name)+        if byName != .orderedSame { return byName == .orderedAscending }+        return left.id.uuidString.lowercased() < right.id.uuidString.lowercased()+    }+}++/// What an add or a rename did. Add and rename share it because a rename can+/// only succeed or be rejected, and an add has the third outcome — restoring an+/// entry the user removed (Req 5.3) — that the caller has to be able to word+/// differently.+public enum WorkTypeAddOutcome: Equatable, Sendable {+    case added(UUID)+    case restored(UUID)+    case rejected(WorkTypeRejection)+}++/// Why a name was refused. Reasons, not sentences: the settings model builds the+/// wording, so the repository never carries reader-facing text.+public enum WorkTypeRejection: Equatable, Sendable {+    case emptyName+    case invalidCharacters+    /// An active entry already holds this normalized name; `existing` is its+    /// stored spelling, so the message can show what it collided with.+    case duplicateActive(existing: String)+    /// A *removed* entry holds it. Adding restores it (Req 5.3); renaming into+    /// it does not, because that would merge two identities, which is out of+    /// scope (Q10) — so the rejection points at restoring instead (Req 5.4).+    case collidesWithRemoved(existing: String)+}++extension LibraryRepository {++    /// The list the settings screen shows: every active entry, plus the removed+    /// entries works still use (Req 1.1, 1.7).+    ///+    /// A removed entry nothing uses is *kept* in the store forever (Q15) and+    /// simply not listed — there is nothing for the reader to do about it, and+    /// re-adding its name restores it either way.+    public func workTypes() async throws -> [WorkTypeSnapshot] {+        try await withLockedContext(mode: .shared, operation: "reading work types") { context in+            let types = try Self.workTypeDirectory(context: context)+            let usage = try Self.workTypeUsageCounts(context: context, types: types)+            return types.identities+                .filter { $0.state != .merged }+                .map {+                    WorkTypeSnapshot(+                        id: $0.id, name: $0.name, state: $0.state,+                        usageCount: usage[$0.id] ?? 0)+                }+                .filter { $0.state == .active || $0.usageCount > 0 }+                .sorted(by: WorkTypeSnapshot.displayOrder)+        }+    }++    /// The same list, for a caller that needs the names and not the counts: the+    /// editor's picker (Reqs 3.1, 3.3).+    ///+    /// Req 1.5's counting is the expensive half of `workTypes()` — it fetches+    /// **every** Work row and groups them — and the picker throws the counts+    /// away, on a read the work editor opens every time it loads. So this one+    /// stops after the directory fold.+    ///+    /// Two consequences the caller has to know. `usageCount` is `0` on every row+    /// and means nothing here; and because the "removed entries works still use"+    /// filter is itself a count, every non-merged entry is returned, removed ones+    /// included. Both are harmless to a picker that shows `.active` rows and+    /// reads only the name — and neither is acceptable on the settings screen,+    /// which keeps calling `workTypes()`.+    public func workTypeOptions() async throws -> [WorkTypeSnapshot] {+        try await withLockedContext(+            mode: .shared, operation: "reading work type options"+        ) { context in+            try Self.workTypeDirectory(context: context).identities+                .filter { $0.state != .merged }+                .map {+                    WorkTypeSnapshot(id: $0.id, name: $0.name, state: $0.state, usageCount: 0)+                }+                .sorted(by: WorkTypeSnapshot.displayOrder)+        }+    }++    /// Adds a name, or restores the removed entry that already holds it+    /// (Reqs 1.2, 5.3).+    ///+    /// The restore keeps the identity and takes the **newly entered spelling**,+    /// so the works that kept the type are using it again, under the name the+    /// reader just typed.+    public func addWorkType(name: String) async throws -> WorkTypeAddOutcome {+        try await withLockedContext(mode: .exclusive, operation: "adding a work type") { context in+            if let error = WorkTypeName.validate(name) { return Self.rejection(error) }+            let trimmed = WorkTypeName.trimmed(name)+            let normalized = WorkTypeName.normalize(name)+            let rows = try context.fetch(FetchDescriptor<WorkTypeEntity>())+            let types = WorkTypeDirectory(entities: rows)++            if let existing = Self.visibleIdentity(named: normalized, in: types) {+                guard existing.state == .removed else {+                    return .rejected(.duplicateActive(existing: existing.name))+                }+                let timestamp = MillisecondInstant.quantize(self.clock.now())+                let identityRows = rows.filter { $0.id == existing.id }+                WorkTypeWriter.setName(trimmed, on: identityRows, at: timestamp)+                WorkTypeWriter.setState(.active, on: identityRows, at: timestamp)+                try self.saveWorkTypes(context, operation: "restoring a work type")+                return .restored(existing.id)+            }++            let timestamp = MillisecondInstant.quantize(self.clock.now())+            let entity = WorkTypeEntity(name: trimmed, timestamp: timestamp)+            context.insert(entity)+            try self.saveWorkTypes(context, operation: "adding a work type")+            return .added(entity.id)+        }+    }++    /// Renames an entry (Req 1.3).+    ///+    /// The duplicate check **excludes the entry being renamed**, which is the+    /// whole of Q13: a case correction (`novel` → `Novel`) normalizes to the+    /// name the entry already has, and rejecting it would make the most likely+    /// rename the one rename that is impossible.+    ///+    /// A rename to the entry's *exact* current spelling succeeds and writes+    /// nothing (Q49).+    public func renameWorkType(id: UUID, to name: String) async throws -> WorkTypeAddOutcome {+        try await withLockedContext(+            mode: .exclusive, operation: "renaming a work type"+        ) { context in+            if let error = WorkTypeName.validate(name) { return Self.rejection(error) }+            let trimmed = WorkTypeName.trimmed(name)+            let normalized = WorkTypeName.normalize(name)+            let rows = try context.fetch(FetchDescriptor<WorkTypeEntity>())+            let types = WorkTypeDirectory(entities: rows)+            guard let identity = types[id], identity.state != .merged else {+                throw LibraryRepositoryError.recordNotFound(type: "WorkTypeEntity", id: id)+            }+            if let clash = Self.visibleIdentity(named: normalized, in: types), clash.id != id {+                return .rejected(+                    clash.state == .removed+                        ? .collidesWithRemoved(existing: clash.name)+                        : .duplicateActive(existing: clash.name))+            }+            // Q49: a rename to the spelling the entry already shows changes+            // nothing, and `setName`'s fresh timestamp would dirty every row of+            // the identity anyway — sync churn for a no-op. The comparison is+            // against the *folded* name, the one the settings screen displayed;+            // a case-only change (Q13) differs from it and writes normally.+            guard trimmed != identity.name else { return .added(id) }+            WorkTypeWriter.setName(+                trimmed, on: rows.filter { $0.id == id },+                at: MillisecondInstant.quantize(self.clock.now()))+            try self.saveWorkTypes(context, operation: "renaming a work type")+            return .added(id)+        }+    }++    /// Removes an entry from the picker (Reqs 1.4, 5.1).+    ///+    /// Nothing is deleted and no work is edited: the entry stays in the store so+    /// works can keep displaying it and so restoring it is the same identity+    /// again (Decision 2, Decision 3).+    public func removeWorkType(id: UUID) async throws {+        try await withLockedContext(+            mode: .exclusive, operation: "removing a work type"+        ) { context in+            let rows = try context.fetch(FetchDescriptor<WorkTypeEntity>())+                .filter { $0.id == id }+            guard !rows.isEmpty else {+                throw LibraryRepositoryError.recordNotFound(type: "WorkTypeEntity", id: id)+            }+            WorkTypeWriter.setState(+                .removed, on: rows, at: MillisecondInstant.quantize(self.clock.now()))+            try self.saveWorkTypes(context, operation: "removing a work type")+        }+    }++    // MARK: - Shared derivation++    /// Req 1.5's counting rule, spelled once.+    ///+    /// **Logical records, not rows** (Q14): a library whose central machinery+    /// presents a duplicate set as one work would otherwise be told it uses a+    /// type three times. The count follows the carrier — the row whose authored+    /// content the group presents — and the canonical id, so works on either+    /// side of a merge count towards the surviving entry (Req 6.2).+    ///+    /// Legacy-typed works are absent by construction: `.legacy` is not+    /// `.configured`, so a work carrying the raw value `novel` never counts+    /// towards a configured entry spelled "novel" (Decision 7).+    private static func workTypeUsageCounts(+        context: ModelContext, types: WorkTypeDirectory+    ) throws -> [UUID: Int] {+        var counts: [UUID: Int] = [:]+        for group in workGroups(try context.fetch(FetchDescriptor<Work>()), types: types).values {+            guard case .configured(let id) =+                types.canonicalized(WorkTypeAssignment.assignment(of: group.carrier))+            else { continue }+            counts[id, default: 0] += 1+        }+        return counts+    }++    /// The folded entry holding a normalized name, among the entries that are+    /// still entries. Merged identities are excluded: they answer for the entry+    /// they merged into, so matching one would report a collision with something+    /// that is no longer in the list.+    ///+    /// Internal because backup import asks the same question of the same+    /// directory (Req 7.3's name matching): settings and import must agree about+    /// which entry a name already belongs to, or an import would add a second+    /// active entry the settings screen would then refuse to let the reader add.+    internal static func visibleIdentity(+        named normalized: String, in types: WorkTypeDirectory+    ) -> WorkTypeDirectory.Identity? {+        types.identities.first {+            $0.state != .merged && $0.normalizedName == normalized+        }+    }++    private static func rejection(_ error: WorkTypeNameError) -> WorkTypeAddOutcome {+        switch error {+        case .empty: .rejected(.emptyName)+        case .containsLineBreaksOrControlCharacters: .rejected(.invalidCharacters)+        }+    }++    private func saveWorkTypes(_ context: ModelContext, operation: String) throws {+        do { try saveStrategy.save(context) }+        catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: operation, reason: String(describing: error))+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift Modified +164 / -70
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex b162364..5685388 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -4,21 +4,29 @@ import SwiftData  /// Runtime opening of the live library, classified then acted on. ///-/// Every store the app can reach is recorded at V5, so there is no in-place-/// migration left to run: the sidecar, the V3 reader and the completion pass are-/// retired (Decision 1). What survives is the readiness contract. The app-/// populates site relationships where the marker says the data pass has not run,-/// validates with `LibraryValidator`, and only then publishes the readiness-/// marker (containing `"5"`, the only version the extension opens — Q14) and-/// clears residual evidence. The extension never populates and never-/// republishes — it requires a `"5"` marker plus a store or fails closed.+/// Every store the app can reach is recorded at V5 or above, and the one+/// conversion left is the `.lightweight` V5 → V6 stage `ModelContainer.init`+/// runs: the sidecar, the V3 reader and the completion pass are retired+/// (Decision 1). What survives is the readiness contract. The app populates site+/// relationships where the marker says the data pass has not run, validates with+/// `LibraryValidator`, and only then publishes the readiness marker (containing+/// `"6"`, the only version the extension opens — Q14, and Q26 of+/// `configurable-work-types`) and clears residual evidence. The extension never+/// populates and never republishes — it requires a `"6"` marker plus a store or+/// fails closed. /// /// An *empty* store is marked ready as soon as it exists, so the app either opens /// a ready library or throws — there is no third state for the reader to resolve.-/// It is marked at `"5"`: there is nothing in it for the pass to populate, so it-/// is already in the state the pass produces (Q26). A populated library still-/// marked `"4"` — one a pre-freeze build certified — runs the pass on its next-/// app open and is republished at `"5"` (Decision 4).+/// It is marked at `"6"`: there is nothing in it for the pass to populate, so it+/// is already in the state the pass produces (Q26).+///+/// Two lagging generations remain openable, and they are different states rather+/// than degrees of one. A populated library still marked `"4"` — one a pre-freeze+/// build certified — runs the relationship pass on its next app open and is+/// republished at `"6"` (Decision 4). One marked `"5"` has already had that pass;+/// what it lacks is the marker generation, so its upgrade is a republication and+/// nothing else. Running the pass over it again would sweep every Entry and Work+/// on the first launch after the update for no change. public extension LibraryRepository {     /// The result of evaluating the live library's fixed-path state under an     /// exclusive lease.@@ -26,7 +34,7 @@ public extension LibraryRepository {         case ready(LibraryRecordCounts)     } -    /// Extension-only readiness result. The extension opens only a `"5"` marker.+    /// Extension-only readiness result. The extension opens only a `"6"` marker.     enum ExtensionResult: Equatable, Sendable {         case ready(LibraryRecordCounts)     }@@ -97,6 +105,7 @@ public extension LibraryRepository {         // holds a reference to it, which is what makes the construction below         // the only live container over this store.         let (container, attachment) = try openLiveContainer(configuration, hooks: hooks)+        seedWorkTypes(on: container)         return (certification.result, makeRepository(             configuration, container, capabilities, clock, saveStrategy,             quarantined: certification.quarantined, diagnostics: certification.diagnostics,@@ -126,7 +135,7 @@ public extension LibraryRepository {     /// acting case states its own sequence — ordering is the substance of Req 2.8,     /// not a detail of it.     ///-    /// Two rules hold across all four acting cases:+    /// Two rules hold across all five acting cases:     ///     /// * **The marker goes after the work it certifies, and the cleanup after the     ///   marker** (Q36). A failure earlier in a sequence has therefore written@@ -153,17 +162,19 @@ public extension LibraryRepository {          case .ready:             // open → validate → clear residual evidence → counts. The pass ran at-            // this library's certification and does not run again (Q29, Q31).+            // this library's certification and does not run again (Q29, Q31),+            // and the marker already records the current generation.             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             let diagnostics = try runPassAndCertify(-                configuration, context: context, saveStrategy: saveStrategy, runPass: false)+                configuration, context: context, saveStrategy: saveStrategy,+                sitePass: false, publishMarker: false)             let counts = try rowCounts(context: context)             hooks.certificationContainerObserver?(container)             return Certification(result: .ready(counts), diagnostics: diagnostics) -        case .markerLagging:-            // open → populate relationships → save → validate → publish `"5"` →+        case .markerLaggingV4:+            // open → populate relationships → save → validate → publish `"6"` →             // clear residual evidence (Decision 4). A `"4"` marker means the             // relationship data pass has not run, not that the schema lags: the             // conversion is what `ModelContainer.init` performs, and populating@@ -176,7 +187,27 @@ public extension LibraryRepository {             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             let diagnostics = try runPassAndCertify(-                configuration, context: context, saveStrategy: saveStrategy, runPass: true)+                configuration, context: context, saveStrategy: saveStrategy,+                sitePass: true, publishMarker: true)+            let counts = try rowCounts(context: context)+            hooks.certificationContainerObserver?(container)+            return Certification(result: .ready(counts), diagnostics: diagnostics)++        case .markerLaggingV5:+            // open (which converts, V5 → V6) → validate → publish `"6"` → clear+            // residual evidence. No data pass: the relationship pass ran at this+            // library's own certification, and the schema step is the+            // `.lightweight` stage the container construction above just+            // performed (Q26, Q37).+            //+            // The marker still goes last, for the same reason as the `"4"`+            // branch: a validation failure leaves `"5"` in place, the extension+            // keeps declining, and the next launch retries.+            let container = try openCertificationContainer(configuration, hooks: hooks)+            let context = ModelContext(container)+            let diagnostics = try runPassAndCertify(+                configuration, context: context, saveStrategy: saveStrategy,+                sitePass: false, publishMarker: true)             let counts = try rowCounts(context: context)             hooks.certificationContainerObserver?(container)             return Certification(result: .ready(counts), diagnostics: diagnostics)@@ -192,10 +223,10 @@ public extension LibraryRepository {                 reason: kind.orphanedReason)          case .unmarkedStore:-            // open → counts → refuse if nonempty → publish `"5"`.+            // open → counts → refuse if nonempty → publish `"6"`.             //             // An *empty* unmarked store is the state a crash between store-            // creation and the marker leaves, or a `publishV5Readiness` that+            // creation and the marker leaves, or a `publishReadiness` that             // failed on a full disk. It is marked and opened, because the             // alternative — failing closed — bricks a library the app can repair             // now that the migration recovery is gone (Decision 2).@@ -206,24 +237,25 @@ public extension LibraryRepository {             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             let counts = try rowCounts(context: context)-            guard counts == .zero else {+            guard counts.holdsNoReaderRecords else {                 throw LibraryRepositoryError.libraryUnavailable(                     operation: "opening current library",                     reason: "unverifiable library: a nonempty store carries no readiness "                         + "marker of any generation")             }-            try publishV5Readiness(at: configuration.readinessMarkerURL)+            try publishReadiness(at: configuration.readinessMarkerURL)             bootstrapLogger.debug("Marked an empty unmarked store as ready")             hooks.certificationContainerObserver?(container)-            return Certification(result: .ready(.zero), diagnostics: .empty)+            return Certification(result: .ready(counts), diagnostics: .empty)          case .pristine:-            // open (which creates) → save → counts → publish `"5"`.+            // open (which creates) → save → counts → publish `"6"`.             //-            // Certified at `"5"`, not `"4"`: an empty store has nothing for the-            // relationship pass to do, so it is already in the state the pass-            // produces (Q26). Marking it `"4"` would leave the share extension-            // declining a library that will never be migrated.+            // Certified at `"6"`, not at a lagging generation: an empty store has+            // nothing for the relationship pass to do, so it is already in the+            // state the pass produces (Q26). Marking it `"4"` or `"5"` would+            // leave the share extension declining a library that will never be+            // migrated.             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             do { try context.save() } catch {@@ -231,7 +263,7 @@ public extension LibraryRepository {                     operation: "creating the empty store", reason: String(describing: error))             }             let counts = try rowCounts(context: context)-            guard counts == .zero else {+            guard counts.holdsNoReaderRecords else {                 // Unreachable unless SQLite resurrects rows from a file family                 // the classifier saw nothing of. Measured rather than assumed,                 // for the reason `.unmarkedStore` measures.@@ -239,9 +271,9 @@ public extension LibraryRepository {                     operation: "opening current library",                     reason: "a store created where none existed came up nonempty")             }-            try publishV5Readiness(at: configuration.readinessMarkerURL)+            try publishReadiness(at: configuration.readinessMarkerURL)             hooks.certificationContainerObserver?(container)-            return Certification(result: .ready(.zero), diagnostics: .empty)+            return Certification(result: .ready(counts), diagnostics: .empty)          case .unrecognised(let reason):             throw LibraryRepositoryError.libraryUnavailable(@@ -249,6 +281,35 @@ public extension LibraryRepository {         }     } +    /// Mints the default work types the app has not seeded yet, on the container+    /// the repository keeps and while the exclusive bootstrap lease is still+    /// held.+    ///+    /// **Here rather than inside certification**, deliberately. It runs on the+    /// *live* container so the seeds are written through whatever mirroring the+    /// open attached, and it runs after the marker so nothing is written into a+    /// library the open has not certified. It is on the app-role path only: the+    /// share extension takes a shared lease and writes nothing, and Req 8.8+    /// requires it to work identically whether the list is empty, partially+    /// synced or full.+    ///+    /// **A failure is recorded, not thrown.** The library is certified, marked+    /// and open by this point; refusing it over three rows of a defaults list+    /// would take the whole app away for a state the next launch retries by+    /// construction (Q25). The same judgement `openLiveContainer` makes about a+    /// mirrored container that will not construct (Q44).+    private static func seedWorkTypes(on container: ModelContainer) {+        do {+            let inserted = try WorkTypeSeeding.run(context: ModelContext(container))+            guard !inserted.isEmpty else { return }+            bootstrapLogger.debug(+                "Seeded \(inserted.count, privacy: .public) default work types")+        } catch {+            bootstrapLogger.error(+                "Seeding the default work types failed: \(String(describing: error), privacy: .public)")+        }+    }+     /// Every `ModelContainer.init` the certification phase performs, announced     /// before it happens. Req 2.1 forbids a refusing state from reaching one at     /// all, so the seam counts attempts rather than successes.@@ -264,7 +325,7 @@ public extension LibraryRepository {     /// bootstrap one: a share sheet that cannot get in must say so quickly.     ///     /// A shared lease is all it needs and all it may have, because it writes-    /// nothing: it requires the readiness marker recording `"5"` plus a store,+    /// nothing: it requires the readiness marker recording `"6"` plus a store,     /// validates, and opens. It never creates a store, never populates     /// relationships and never republishes the marker (Req 2.13); every     /// pre-certification state fails closed with the shipped message.@@ -320,11 +381,15 @@ extension LibraryRepository {         var quarantined: [String: LibraryValidationError] { diagnostics.quarantineMap() }     } -    /// Opens the fixed-path store with the live V5 schema and-    /// `AsterismV5MigrationPlan`, which since P1 declares that one schema and no-    /// stages (Req 3.1, 3.3). Nothing this opener reaches needs a stage: a store-    /// recorded below V5 is refused by `classify` before any container is-    /// constructed (Req 2.9, Decision 1).+    /// Opens the fixed-path store with the live V6 schema and+    /// `AsterismV6MigrationPlan`, which declares `[V5, V6]` and one lightweight+    /// stage: this call is where an installed V5 library is converted, and the+    /// only place it happens.+    ///+    /// A store recorded below V5 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.     ///     /// Mirroring defaults to off, and every certification-phase caller takes the     /// default: the store is opened `.none` for creation, the site-relationship@@ -337,13 +402,13 @@ extension LibraryRepository {         at storeURL: URL,         mirroring cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .none     ) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV5.self)+        let schema = Schema(versionedSchema: AsterismSchemaV6.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 V5,-            // so the name is one version 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 V6,+            // so the name is three versions behind, and renaming it buys nothing on+            // a path that opens the owner's only library.             "AsterismV3",             schema: schema,             url: storeURL,@@ -351,17 +416,20 @@ extension LibraryRepository {         )         return try ModelContainer(             for: schema,-            migrationPlan: AsterismV5MigrationPlan.self,+            migrationPlan: AsterismV6MigrationPlan.self,             configurations: [storeConfiguration]         )     } -    /// Publishes readiness for a migrated library (schema version 5) — the only-    /// version the share extension opens, and the only version production-    /// publishes (Q32): every certification path runs the relationship pass-    /// first, so a `"4"` marker can now only come from a pre-freeze build's-    /// library, and the app republishes it here after the pass.-    public static func publishV5Readiness(at url: URL) throws {+    /// Publishes readiness at the current marker generation — the only version+    /// the share extension opens, and the only version production publishes+    /// (Q32): every certification path has either run the relationship pass or+    /// established it has nothing to do, so `"4"` and `"5"` markers can only come+    /// from earlier builds' libraries and the app republishes here.+    ///+    /// Unversioned by name on purpose: it always writes the generation the build+    /// certifies at, and the digit has moved twice already.+    public static func publishReadiness(at url: URL) throws {         do {             try Data("\(extensionOpenableMarkerVersion)\n".utf8).write(to: url, options: .atomic)             try FileManager.default.setAttributes(@@ -406,7 +474,7 @@ extension LibraryRepository {     }      /// The certification tail the acting cases share, in the order Q36 pins: the-    /// site-relationship pass, then store validation, then the `"5"` marker, and+    /// site-relationship pass, then store validation, then the `"6"` marker, and     /// only then the residual evidence the marker replaces.     ///     /// The pass runs BEFORE `validateStore`: diagnostics feed the session's@@ -415,7 +483,7 @@ extension LibraryRepository {     /// quarantines the pass had just made obsolete.     ///     /// The marker goes after the work and the cleanup after the marker (Q36).-    /// `"5"` is published only once the pass's one save has committed and the+    /// `"6"` is published only once the pass's one save has committed and the     /// store validated — the "marker last" Q15 asks for, where "last" means after     /// the work, not after housekeeping. The historical marker and the migration     /// artefact are the recovery evidence for the state this call is leaving, so@@ -423,16 +491,28 @@ extension LibraryRepository {     /// filenames outlive the migration because the classifier reads them for     /// presence (Q19).     ///-    /// `runPass` is false only for a library the marker already reports as-    /// populated: the pass ran at its certification and does not run again (Q29,-    /// Q31), and there is no new marker to publish for it either.+    /// **Two flags, not one** (Q37 of `configurable-work-types`). The three+    /// callers want three different combinations, and a single `runPass` boolean+    /// could only express two of them:+    ///+    /// | State | `sitePass` | `publishMarker` |+    /// |---|---|---|+    /// | `.markerLaggingV4` | true | true |+    /// | `.markerLaggingV5` | false | true |+    /// | `.ready` | false | false |+    ///+    /// `sitePass` is false wherever the marker already reports the relationships+    /// as populated: the pass ran at that library's certification and does not+    /// run again (Q29, Q31). `publishMarker` is false only where the marker+    /// already records the current generation.     static func runPassAndCertify(         _ configuration: LibraryConfiguration,         context: ModelContext,         saveStrategy: any RepositorySaveStrategy,-        runPass: Bool+        sitePass: Bool,+        publishMarker: Bool     ) throws -> LibraryDiagnostics {-        if runPass {+        if sitePass {             do {                 try SiteRelationshipPopulationPass.run(context: context, saveStrategy: saveStrategy)             } catch {@@ -442,7 +522,7 @@ extension LibraryRepository {             }         }         let diagnostics = try validateStore(context: context)-        if runPass { try publishV5Readiness(at: configuration.readinessMarkerURL) }+        if publishMarker { try publishReadiness(at: configuration.readinessMarkerURL) }         // The current marker governs, so a historical one is stale wherever it is         // left, and so is a migration artefact no path resumes from any more         // (Req 1.2). Both are absent on most calls, which `try?` covers along@@ -482,10 +562,16 @@ extension LibraryRepository {         }     } -    /// Schema versions the app opens: `"4"` is a library the relationship-    /// migration has not run over yet, `"5"` one it has.+    /// Marker generations the app opens: `"4"` is a library the relationship+    /// pass has not run over yet, `"5"` one it has but whose marker predates+    /// `configurable-work-types`, `"6"` a fully certified one.+    ///+    /// Every generation the project has ever published stays here. Shipping a+    /// new one without extending the set fails every device still on the old+    /// marker closed (`docs/agent-notes/schema-migration.md`).     static let appOpenableMarkerVersions: Set<String> = [-        markerVersionAwaitingRelationshipPass, extensionOpenableMarkerVersion,+        markerVersionAwaitingRelationshipPass, markerVersionAwaitingRepublication,+        extensionOpenableMarkerVersion,     ]      /// The marker a library carries when the relationship data pass has not run@@ -495,16 +581,22 @@ extension LibraryRepository {     /// installed library (Req 3.5).     static let markerVersionAwaitingRelationshipPass = "4" +    /// The marker a library carries when the relationship pass has run but the+    /// marker generation predates `configurable-work-types` (Q26). Nothing is+    /// owed here but the republication: the V5 → V6 step is the `.lightweight`+    /// stage `ModelContainer.init` runs. Frozen persisted state (Req 3.5).+    static let markerVersionAwaitingRepublication = "5"+     /// The only version the share extension opens (Q14). Frozen persisted state     /// (Req 3.5).-    static let extensionOpenableMarkerVersion = "5"--    /// App side: the marker must declare a version the app opens. The migration-    /// exists precisely for libraries still at `"4"`, so demanding `"5"` here-    /// would throw on every library it exists for. A version outside the set,-    /// or an unreadable marker, fails closed. Returns the validated version so-    /// the bootstrap can tell a `"4"` library — one the relationship pass must-    /// still run over — from a migrated one (Q31).+    static let extensionOpenableMarkerVersion = "6"++    /// App side: the marker must declare a version the app opens. The upgrade+    /// paths exist precisely for libraries still at `"4"` and `"5"`, so demanding+    /// `"6"` here would throw on every library they exist for. A version outside+    /// the set, or an unreadable marker, fails closed. Returns the validated+    /// version so the bootstrap can tell the lagging generations apart from a+    /// certified one (Q31).     @discardableResult     static func validateMarkerContentForApp(at url: URL) throws -> String {         let version = try readMarkerVersion(at: url)@@ -526,8 +618,10 @@ extension LibraryRepository {     /// extension invocations can hold `LOCK_SH` concurrently and both attempt     /// the conversion, and the extension can be invoked when the app is not     /// running at all. Refusing here, before any container is constructed, is-    /// what keeps the conversion in the app. Accepting `"4"` as the app-side-    /// check does would defeat that entirely.+    /// what keeps the conversion in the app. Accepting `"4"` or `"5"` as the+    /// app-side check does would defeat that entirely — and `"5"` is exactly the+    /// window between the app being updated and first launched, which+    /// `configurable-work-types` Req 8.7 requires to fail safely.     static func validateMarkerContentForExtension(at url: URL) throws {         let version = try readMarkerVersion(at: url)         if version == extensionOpenableMarkerVersion { return }
Packages/AsterismCore/Sources/AsterismCore/BackupV5Codec.swift Added +218 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV5Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Codec.swiftnew file mode 100644index 0000000..ae0ebba--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Codec.swift@@ -0,0 +1,218 @@+import Foundation++/// The strict 5/6 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 format's type-list rules.+///+/// Cloned from `BackupV4Codec` rather than grown out of it — a shipped archive+/// format is never redefined in place. What the two share is the *record-level*+/// checking body (`BackupArchiveReferenceChecks`) and the envelope shape+/// (`BackupArchiveShapeValidator`), which are the same rules over the same+/// records; what is restated here is everything that names a version.+///+/// The capability gate is pinned to the literal `"m4"`, for the reason the 4/4+/// codec pins it: the payload is frozen the moment it ships, and a later+/// `AsterismCapabilities.current` must not change what a 5/6 backup declares.+public enum BackupV5Codec {+    /// Pinned literally. The 5/6 format ships at the m4 gate; a future gate flip+    /// cannot retroactively change what these files say.+    static let gate = "m4"++    // MARK: - Encode++    public static func encode(+        payload: BackupV5Payload,+        metadata: BackupV5Metadata+    ) throws -> Data {+        let encoder = BackupCanonicalJSON.encoder()++        let payloadData = try encoder.encode(payload)+        let checksum = BackupCanonicalJSON.sha256Hex(payloadData)++        let document = BackupV5Document(+            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 5/6 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. `(5, 5)` and `(6, 6)` 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.+    public static func decode(_ data: Data) throws -> BackupV5Document {+        do {+            try DuplicateJSONKeyValidator.validate(data)+            try BackupV5ShapeValidator.validate(data)++            let document = try BackupCanonicalJSON.decoder()+                .decode(BackupV5Document.self, from: data)++            guard document.backupFormatVersion == BackupV5Document.formatVersion else {+                throw BackupV5CodecError.invalidFormatVersion(document.backupFormatVersion)+            }+            guard document.databaseSchemaVersion == BackupV5Document.schemaVersion else {+                throw BackupV5CodecError.invalidSchemaVersion(document.databaseSchemaVersion)+            }+            guard document.capabilityGate == Self.gate else {+                throw BackupV5CodecError.unsupportedGate(document.capabilityGate)+            }++            guard document.entryCount == document.payload.entries.count else {+                throw BackupV5CodecError.countMismatch(+                    field: "entryCount",+                    expected: document.entryCount,+                    actual: document.payload.entries.count+                )+            }+            guard document.workCount == document.payload.works.count else {+                throw BackupV5CodecError.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 BackupV5CodecError.checksumMismatch(+                    expected: document.checksum,+                    actual: computedChecksum+                )+            }++            try BackupV5ReferenceValidator.validate(payload: document.payload)++            return document+        } catch let error as BackupV5CodecError { throw error }+        catch let error as BackupCodecError { throw error }+        catch {+            throw BackupV5CodecError.decodingFailed(reason: String(describing: error))+        }+    }++}++// MARK: - V5 Codec Error++public enum BackupV5CodecError: Error, Equatable, Sendable, CustomStringConvertible {+    case encodingFailed(reason: String)+    case decodingFailed(reason: String)+    case invalidFormatVersion(Int)+    case invalidSchemaVersion(Int)+    case unsupportedGate(String)+    case countMismatch(field: String, expected: Int, actual: Int)+    case checksumMismatch(expected: String, actual: String)+    case unresolvedReference(type: String, id: String, reference: String)+    case invalidStateTuple(type: String, id: String, reason: String)++    /// The shared record-level checks' finding, named as a 5/6 refusal.+    internal init(_ issue: BackupArchiveReferenceIssue) {+        switch issue {+        case .unresolvedReference(let type, let id, let reference):+            self = .unresolvedReference(type: type, id: id, reference: reference)+        case .invalidStateTuple(let type, let id, let reason):+            self = .invalidStateTuple(type: type, id: id, reason: reason)+        }+    }++    public var description: String {+        switch self {+        case .encodingFailed(let reason): "Backup V5 encoding failed: \(reason)"+        case .decodingFailed(let reason): "Backup V5 decoding failed: \(reason)"+        case .invalidFormatVersion(let v): "Backup V5 unsupported format version: \(v)"+        case .invalidSchemaVersion(let v): "Backup V5 unsupported schema version: \(v)"+        case .unsupportedGate(let g): "Backup V5 unsupported capability gate: \(g)"+        case .countMismatch(let field, let expected, let actual):+            "Backup V5 \(field) mismatch: header says \(expected), payload has \(actual)"+        case .checksumMismatch(let expected, let actual):+            "Backup V5 checksum mismatch: expected \(expected), computed \(actual)"+        case .unresolvedReference(let type, let id, let reference):+            "Backup V5 \(type) \(id) has unresolved reference: \(reference)"+        case .invalidStateTuple(let type, let id, let reason):+            "Backup V5 invalid \(type) tuple \(id): \(reason)"+        }+    }+}++// MARK: - V5 Metadata++public struct BackupV5Metadata: Sendable {+    public let appBuild: String+    public let exportedAt: Date++    public init(appBuild: String, exportedAt: Date) {+        self.appBuild = appBuild+        self.exportedAt = exportedAt+    }+}++// MARK: - V5 Shape Validator++/// Root-strict envelope validation, over the shape both formats share.+internal enum BackupV5ShapeValidator {+    static func validate(_ data: Data) throws {+        try BackupArchiveShapeValidator.validate(data)+    }+}++// MARK: - V5 Reference Validator++/// The shared record checks, plus the two things only 5/6 carries: the type list+/// and the works' references into it.+///+/// **What it deliberately does not do.** It does not refuse a `workTypeID` or a+/// `canonicalID` naming an entry the list lacks: the live library tolerates an+/// unresolved assignment as a rendered state rather than corruption+/// ([8.6](../../../../specs/configurable-work-types/requirements.md#8.6)), and an+/// archive that refused over one would break+/// [7.1](../../../../specs/configurable-work-types/requirements.md#7.1) for+/// exactly the reader whose sync has not settled (Q24). Nor does it constrain+/// `legacyType` to the closed `WorkType` set (Q34), nor require a `merged` entry+/// to name a target: the fold produces a target-less merged identity when no+/// merged row carries one (Q40), and the chase answers for it anyway.+///+/// What it does refuse is a payload that contradicts itself: two records for one+/// type identity — the exporter folds, so a duplicate id means the file was not+/// written by this exporter and the import's id-matching would be ambiguous —+/// and a work claiming both a configured type and a legacy one.+internal enum BackupV5ReferenceValidator {+    static func validate(payload: BackupV5Payload) throws {+        do {+            try BackupArchiveReferenceChecks.validate(+                entries: payload.entries,+                works: payload.works.map(\.referenceRecord),+                sites: payload.sites,+                titlePatterns: payload.titlePatterns,+                urlRules: payload.urlRules,+                formatLabel: "V5")+        } catch let issue as BackupArchiveReferenceIssue {+            throw BackupV5CodecError(issue)+        }++        let typeIDs = Set(payload.workTypes.map(\.id))+        guard typeIDs.count == payload.workTypes.count else {+            throw BackupV5CodecError.invalidStateTuple(+                type: "Payload", id: "V5", reason: "duplicate work type ID")+        }+        for work in payload.works where work.workTypeID != nil && work.legacyType != nil {+            throw BackupV5CodecError.invalidStateTuple(+                type: "Work", id: work.id.uuidString,+                reason: "a work carries a configured type or a legacy value, never both")+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift Added +277 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swiftnew file mode 100644index 0000000..a4eff6a--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift@@ -0,0 +1,277 @@+import Foundation+import SwiftData++// MARK: - V5 Snapshot Providing++/// Provides one coherent 5/6 payload under a shared lock. Isolated from+/// persistence so export can be unit-tested with injected snapshots.+public protocol BackupV5SnapshotProviding: Sendable {+    func backupV5Snapshot() async throws -> BackupV5Payload+}++// MARK: - V5 Export Errors++/// The 5/6 export's refusals. The same four states the 4/4 export names — the+/// refusals are facts about the library, not the format — restated under this+/// format's name so a reader is told which export declined.+///+/// One 4/4 refusal is deliberately absent from the 5/6 path in practice rather+/// than from the enum: `unrepresentableValue` no longer fires on a work's type.+/// The 5/6 record carries any stored raw verbatim (Q34), the projection half of+/// [7.1](../../../../specs/configurable-work-types/requirements.md#7.1) —+/// "export succeeds for works of any type". The other half is upstream:+/// `snapshot(_ work:)` still refuses an unrecognised `typeRaw` until task 8.2+/// (Decision 5) removes that throw, so 7.1 holds only once both halves land.+public enum BackupV5ExportError: Error, Equatable, Sendable, CustomStringConvertible {+    /// The store holds a **torn** identity group: one application UUID over rows+    /// that disagree about something the reader wrote.+    case tornGroups(TornGroupsPayload)+    /// A record holds a stored value the wire format cannot represent.+    case unrepresentableValue(record: String, field: String, value: String)+    /// A record cites a rule no row in the library holds. Transient by nature.+    case referencesStillArriving(detail: String)++    case snapshotFailed(reason: String)+    case encodingFailed(reason: String)+    case stagingFailed(reason: String)++    /// The shared projection's refusal, named as a 5/6 refusal. The projection+    /// is one body for both formats, so its findings arrive spelled 4/4.+    internal init(_ error: BackupV4ExportError) {+        switch error {+        case .tornGroups(let payload): self = .tornGroups(payload)+        case .unrepresentableValue(let record, let field, let value):+            self = .unrepresentableValue(record: record, field: field, value: value)+        case .referencesStillArriving(let detail): self = .referencesStillArriving(detail: detail)+        case .snapshotFailed(let reason): self = .snapshotFailed(reason: reason)+        case .encodingFailed(let reason): self = .encodingFailed(reason: reason)+        case .stagingFailed(let reason): self = .stagingFailed(reason: reason)+        }+    }++    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 V5 snapshot failed: \(reason)"+        case .encodingFailed(let reason): "Backup V5 encoding failed: \(reason)"+        case .stagingFailed(let reason): "Backup V5 staging failed: \(reason)"+        }+    }+}++// MARK: - LibraryRepository V5 Snapshot++extension LibraryRepository: BackupV5SnapshotProviding {+    /// Provides a coherent 5/6 backup payload under a shared lock.+    public func backupV5Snapshot() async throws -> BackupV5Payload {+        let outcome: Result<BackupV5Payload, BackupV5ExportError> =+            try await withLockedBackupContext { context in+                do { return .success(try Self.projectV5Payload(context: context)) }+                catch let error as BackupV5ExportError { return .failure(error) }+                catch let error as BackupV4ExportError { return .failure(BackupV5ExportError(error)) }+            }+        return try outcome.get()+    }++    /// The whole 5/6 snapshot, from a context.+    ///+    /// Everything but the type list and the Work records is+    /// `projectCommonArchiveRecords`, which is what keeps a 5/6 and a 4/4 backup+    /// of one library describing one library.+    internal static func projectV5Payload(context: ModelContext) throws -> BackupV5Payload {+        let common: ArchiveCommonProjection+        do {+            // No type refusal (Req 7.1): a raw value outside the closed set is+            // legal data now, and the 5/6 Work record carries it verbatim.+            common = try projectCommonArchiveRecords(+                context: context, refusingUnrepresentableWorkTypes: false)+        } catch let error as BackupV4ExportError {+            throw BackupV5ExportError(error)+        }++        // 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 {+            BackupV5WorkTypeRecord(+                id: $0.id, name: $0.name, stateRaw: $0.state.rawValue,+                canonicalID: $0.canonicalID, createdAt: $0.createdAt,+                modifiedAt: $0.modifiedAt)+        }++        let payload = BackupV5Payload(+            entries: common.entries,+            works: try common.groups.works.map {+                try mapV5WorkRecord(+                    $0, canonicalWorkIDs: common.groups.canonicalWorkIDs,+                    rewrites: common.rewrites, types: directory)+            },+            sites: common.sites,+            titlePatterns: common.titlePatterns,+            urlRules: common.urlRules,+            workTypes: workTypes+        )+        do {+            try requireCitationsResolve(+                entries: payload.entries,+                workIdentityRules: payload.works.map { ($0.id, $0.urlIdentityRuleID) },+                titlePatternIDs: Set(payload.titlePatterns.map(\.id)),+                urlRuleIDs: Set(payload.urlRules.map(\.id)))+        } catch let error as BackupV4ExportError {+            throw BackupV5ExportError(error)+        }+        return payload+    }++    /// The 5/6 Work record. Identical to the 4/4 mapper but for the type+    /// columns, which come from the **carrier**'s assignment — the same row the+    /// rest of a group's authored content comes from.+    ///+    /// The stored `workTypeID` is written verbatim, never canonicalized: the+    /// archive carries the merged entries too, so the import's chase resolves a+    /// pointer at a non-surviving entry the same way this device's directory+    /// does. A 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.+    ///+    /// `typeName` is set for configured types only. A legacy or unrecognised+    /// value *is* its own label and travels in `legacyType`; a second copy of it+    /// would be a field that can disagree with the first.+    internal static func mapV5WorkRecord(+        _ group: WorkGroup,+        canonicalWorkIDs: [UUID: UUID],+        rewrites: [UUID: Int] = [:],+        types: WorkTypeDirectory+    ) throws -> BackupV5Work {+        let snap = try snapshot(group, canonicalWorkIDs: canonicalWorkIDs, types: types)+        let work = group.representative+        let assignment = WorkTypeAssignment.assignment(of: group.carrier)+        let workTypeID: UUID?+        let legacyType: String?+        let typeName: String?+        switch assignment {+        case .none:+            (workTypeID, legacyType, typeName) = (nil, nil, nil)+        case .configured(let id):+            (workTypeID, legacyType, typeName) = (id, nil, types.resolve(id)?.name)+        case .legacy(let raw), .unrecognised(let raw):+            (workTypeID, legacyType, typeName) = (nil, raw, nil)+        }+        return BackupV5Work(+            id: snap.id,+            displayTitle: snap.displayTitle,+            lastParsedTitle: snap.lastParsedTitle,+            siteHostname: snap.siteHostname,+            urlIdentity: snap.urlIdentity,+            urlIdentityState: work.urlIdentityState,+            urlIdentityRuleID: work.urlIdentityRuleID,+            urlIdentityRuleVersion: work.urlIdentityRuleID+                .flatMap { rewrites[$0] } ?? work.urlIdentityRuleVersion,+            workURL: snap.workURLString,+            genericNotes: snap.genericNotes,+            workTypeID: workTypeID,+            legacyType: legacyType,+            typeName: typeName,+            genreTags: snap.genreTags,+            titleProvenance: snap.titleProvenance,+            createdAt: snap.createdAt,+            modifiedAt: snap.modifiedAt,+            entryIDs: snap.entries.map(\.id)+                .sorted { $0.uuidString.lowercased() < $1.uuidString.lowercased() }+        )+    }+}++// MARK: - V5 Exporter++/// Orchestrates coherent 5/6 snapshot → validated encoding → staging.+///+/// It decode-validates its own bytes before sharing, so a produced file is+/// always a valid strict 5/6 document — the 4/4 exporter's contract, kept.+public final class BackupV5Exporter: Sendable {+    private let repository: any BackupV5SnapshotProviding+    private let stagingDirectory: URL++    public init(+        repository: any BackupV5SnapshotProviding,+        stagingDirectory: URL+    ) {+        self.repository = repository+        self.stagingDirectory = stagingDirectory+    }++    public func export(metadata: BackupV5Metadata) async throws -> BackupExportResult {+        let payload: BackupV5Payload+        do {+            payload = try await repository.backupV5Snapshot()+        } catch let error as BackupV5ExportError {+            throw error+        } catch let error as BackupV4ExportError {+            throw BackupV5ExportError(error)+        } catch {+            throw BackupV5ExportError.snapshotFailed(reason: String(describing: error))+        }++        let encoded: Data+        do {+            encoded = try BackupV5Codec.encode(payload: payload, metadata: metadata)+        } catch {+            throw BackupV5ExportError.encodingFailed(reason: String(describing: error))+        }++        do {+            let decoded = try BackupV5Codec.decode(encoded)+            guard decoded.payload == payload else {+                throw BackupV5ExportError.encodingFailed(reason: "decode-validation payload mismatch")+            }+        } catch let error as BackupV5ExportError {+            throw error+        } catch {+            throw BackupV5ExportError.encodingFailed(reason: "decode-validation failed: \(error)")+        }++        do {+            try FileManager.default.createDirectory(+                at: stagingDirectory, withIntermediateDirectories: true)+            let fileURL = stagingDirectory.appending(+                path: ExportStaging.backupFilename(+                    version: "v5", exportedAt: metadata.exportedAt))+            do {+                try ExportStaging.write(encoded, to: fileURL)+            } catch {+                throw BackupV5ExportError.stagingFailed(reason: String(describing: error))+            }+            return BackupExportResult(fileURL: fileURL)+        } catch let error as BackupV5ExportError {+            throw error+        } catch {+            throw BackupV5ExportError.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 4/4 files a previous build staged.+    public func scavengeStaleFiles() {+        ExportStaging.scavengeBackups(in: stagingDirectory)+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift Added +269 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swiftnew file mode 100644index 0000000..2ec8520--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift@@ -0,0 +1,269 @@+import Foundation+import SwiftData++// The type-list half of a 5/6 import (Reqs 7.3, 7.4, 7.7, 7.8).+//+// **Additive, and never a rewrite of a work.** Import brings entries the library+// lacks, restores one the archive says is active, and redirects the archive's+// identifiers at the local entries that already answer for them — all of it+// through rows, because a work cites its type by identifier and Decision 3 forbids+// the bulk write that changing those citations would be.+//+// **Every insert is match-guarded and every write is value-guarded**, which is+// where idempotence comes from (Req 7.7): an alias row id-matches on the second+// import, a mint resolves, and a restore of an entry that is already active finds+// nothing to write.++extension LibraryRepository {++    /// Merges the archive's type list into the live one and mints or aliases the+    /// entries its works cite but its list could not carry. Does not save unless+    /// something changed.+    ///+    /// - Parameters:+    ///   - exportedAt: the archive's own timestamp, worn by rows minted from a+    ///     work's `typeName` — there is no list record to take one from (Q33).+    ///   - importedAt: the quantized import-time clock, worn *only* by the+    ///     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(+        _ payload: BackupV5Payload,+        exportedAt: Date,+        importedAt: Date,+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws {+        var rows = try context.fetch(FetchDescriptor<WorkTypeEntity>())+        var rowsByID: [UUID: [WorkTypeEntity]] = [:]+        for row in rows { rowsByID[row.id, default: []].append(row) }+        var local = WorkTypeDirectory(entities: rows)+        var wrote = false++        func insert(+            id: UUID, name: String, state: WorkTypeState, canonicalID: UUID?,+            createdAt: Date, modifiedAt: Date+        ) {+            let row = WorkTypeEntity(+                id: id, name: name, state: state, canonicalID: canonicalID,+                timestamp: modifiedAt)+            // The record's own `createdAt`, which `timestamp:` would otherwise+            // have overwritten with its `modifiedAt`. Survivor election is+            // earliest-created, so this is not decoration.+            row.createdAt = createdAt+            context.insert(row)+            rows.append(row)+            rowsByID[id, default: []].append(row)+            local = WorkTypeDirectory(entities: rows)+            wrote = true+        }++        for entry in archivedTypeIdentities(payload.workTypes) {+            // Req 7.8: a name that is empty or carries control characters never+            // becomes a visible type. A `merged` entry is not a visible type —+            // it is a redirection — so it is admitted whatever it is called,+            // which is what keeps the chase total for works pointing at it. A+            // work pointing at a *dropped* entry keeps its stored identifier and+            // renders as unresolved (Q36); there is no label to give it, because+            // under Decision 8 the work never stored one.+            if entry.state != .merged, WorkTypeName.validate(entry.name) != nil { continue }++            if let identity = local[entry.id] {+                // Present on both sides. The local spelling stands (Req 7.3) —+                // it is the one this library has been showing — and only the+                // state can move.+                wrote =+                    applyImportedState(+                        entry, to: identity, rows: rowsByID[identity.id] ?? [],+                        importedAt: importedAt) || wrote+                continue+            }++            if entry.state != .merged,+                let match = visibleIdentity(named: entry.normalizedName, in: local)+            {+                // Q29: the same name, a different identity — another library's+                // entry. It comes in as an **alias row**, keeping the archive's+                // identifier so every imported work reference and every imported+                // `canonicalID` resolves through the chase instead of dangling+                // forever; sync will never deliver the other library's rows.+                insert(+                    id: entry.id, name: entry.name, state: .merged, canonicalID: match.id,+                    createdAt: entry.createdAt, modifiedAt: entry.modifiedAt)+                // ...and then Req 7.3's state rule lands on the entry that+                // actually answers for the name. `match` is non-merged by+                // construction, so it is its own chase endpoint.+                wrote =+                    applyImportedState(+                        entry, to: match, rows: rowsByID[match.id] ?? [],+                        importedAt: importedAt) || wrote+                continue+            }++            // Nothing local claims it: it arrives with the state the archive+            // recorded, merged entries included, so importing can never+            // resurrect a non-surviving entry (Req 6.3, 7.2).+            insert(+                id: entry.id, name: entry.name, state: entry.state,+                canonicalID: entry.canonicalID, createdAt: entry.createdAt,+                modifiedAt: entry.modifiedAt)+        }++        // Req 7.4: a work citing an entry neither the archive's list nor this+        // library holds. Its `typeName` snapshot is the only thing that can+        // answer for it — an identifier carries no name (Q29).+        for citation in unresolvedTypeCitations(payload.works, in: local) {+            guard WorkTypeName.validate(citation.name) == nil else { continue }+            let name = WorkTypeName.trimmed(citation.name)+            // Re-asked per citation rather than once: a mint two citations ago is+            // what the next same-named one has to alias to, or one archive would+            // seed two active entries spelled the same.+            if let match = visibleIdentity(named: WorkTypeName.normalize(name), in: local) {+                insert(+                    id: citation.id, name: name, state: .merged, canonicalID: match.id,+                    createdAt: exportedAt, modifiedAt: exportedAt)+            } else {+                insert(+                    id: citation.id, name: name, state: .active, canonicalID: nil,+                    createdAt: exportedAt, modifiedAt: exportedAt)+            }+        }++        guard wrote else { return }+        do { try saveStrategy.save(context) }+        catch {+            throw LibraryRepositoryError.libraryUnavailable(+                operation: "merging the imported work-type list",+                reason: String(describing: error))+        }+    }++    // MARK: - The archive's own list++    /// One entry of the archive's list, after the list has been folded against+    /// itself.+    private struct ArchivedTypeIdentity {+        let id: UUID+        let name: String+        let normalizedName: String+        let state: WorkTypeState+        let canonicalID: UUID?+        let createdAt: Date+        let modifiedAt: Date+    }++    /// The archive's list as identities, with its **own** normalized-name+    /// collisions already resolved (Req 7.3's "folded against itself first").+    ///+    /// An archive can hold two entries spelled the same — it is a snapshot of a+    /// library taken before that library's own reconciler had converged them, or+    /// of two libraries' lists merged by a previous import. Resolving them here,+    /// by the same survivor rules and using the records' own timestamps, means the+    /// merge below never inserts a collision the reconciler would have to clean up+    /// afterwards.+    ///+    /// Returned in identifier order, so an interrupted import resumes into the+    /// same shape on any device.+    private static func archivedTypeIdentities(+        _ records: [BackupV5WorkTypeRecord]+    ) -> [ArchivedTypeIdentity] {+        let directory = WorkTypeDirectory(+            rows: records.map {+                // One timestamp on the wire, both fields in the store: the+                // per-field history is a store concern that archives do not carry+                // (Decision 10), and `modifiedAt` is the fold's max.+                WorkTypeDirectory.Row(+                    id: $0.id, name: $0.name, nameModifiedAt: $0.modifiedAt,+                    stateRaw: $0.stateRaw, stateModifiedAt: $0.modifiedAt,+                    canonicalID: $0.canonicalID, createdAt: $0.createdAt)+            })++        var mergedInto: [UUID: UUID] = [:]+        var electedName: [UUID: String] = [:]+        var electedState: [UUID: WorkTypeState] = [:]+        for collision in WorkTypeReconciler.collisions(in: directory) {+            guard let survivor = collision.first else { continue }+            electedName[survivor.id] =+                (WorkTypeReconciler.elected(+                    collision, timestamp: \.nameModifiedAt, value: \.name) ?? survivor).name+            electedState[survivor.id] =+                (WorkTypeReconciler.elected(+                    collision, timestamp: \.stateModifiedAt, value: \.state.rawValue)+                    ?? survivor).state+            for loser in collision.dropFirst() { mergedInto[loser.id] = survivor.id }+        }++        return directory.identities.map { identity in+            if let target = mergedInto[identity.id] {+                return ArchivedTypeIdentity(+                    id: identity.id, name: identity.name,+                    normalizedName: identity.normalizedName, state: .merged,+                    canonicalID: target, createdAt: identity.createdAt,+                    modifiedAt: identity.modifiedAt)+            }+            let name = electedName[identity.id] ?? identity.name+            return ArchivedTypeIdentity(+                id: identity.id, name: name, normalizedName: WorkTypeName.normalize(name),+                state: electedState[identity.id] ?? identity.state,+                canonicalID: identity.canonicalID, createdAt: identity.createdAt,+                modifiedAt: identity.modifiedAt)+        }+    }++    // MARK: - The state rule++    /// Req 7.3's state rule, applied to a **local** identity.+    ///+    /// - `merged` is terminal on either side. A local entry that merged into+    ///   something is not a state an archive can undo (Req 6.3), and an archive+    ///   that recorded a merge carries it over.+    /// - Otherwise active wins over removed — and the restore it performs is+    ///   stamped with the import-time clock rather than the archive's, because it+    ///   is the reader restoring an entry *now* and it must assert over the+    ///   removal that is already there (Q33).+    /// - Anything else writes nothing, spelling included: an entry present on+    ///   both sides keeps the name this library shows.+    private static func applyImportedState(+        _ entry: ArchivedTypeIdentity,+        to identity: WorkTypeDirectory.Identity,+        rows: [WorkTypeEntity],+        importedAt: Date+    ) -> Bool {+        guard identity.state != .merged else { return false }+        if entry.state == .merged {+            // The archive's own timestamp: the fold makes `merged` absorbing, so+            // the value is inert, and copying it keeps converged rows identical+            // on every device given the same archive.+            return WorkTypeWriter.setState(+                .merged, canonicalID: entry.canonicalID, on: rows, at: entry.modifiedAt) > 0+        }+        guard entry.state == .active, identity.state == .removed else { return false }+        return WorkTypeWriter.setState(.active, on: rows, at: importedAt) > 0+    }++    // MARK: - Work citations++    private struct ArchivedTypeCitation {+        let id: UUID+        let name: String+    }++    /// The distinct type identifiers the archive's works cite that the merged+    /// list still cannot resolve, each with the display name the exporter+    /// snapshotted beside it, in identifier order.+    ///+    /// 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: [BackupV5Work], in local: WorkTypeDirectory+    ) -> [ArchivedTypeCitation] {+        var seen: Set<UUID> = []+        var citations: [ArchivedTypeCitation] = []+        for work in works {+            guard let id = work.workTypeID, let name = work.typeName,+                local.resolve(id) == nil, seen.insert(id).inserted+            else { continue }+            citations.append(ArchivedTypeCitation(id: id, name: name))+        }+        return citations.sorted { $0.id.uuidString.lowercased() < $1.id.uuidString.lowercased() }+    }+}
Packages/AsterismCore/Sources/AsterismCore/ArchiveWorkRecord.swift Added +93 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArchiveWorkRecord.swift b/Packages/AsterismCore/Sources/AsterismCore/ArchiveWorkRecord.swiftnew file mode 100644index 0000000..374e2aa--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ArchiveWorkRecord.swift@@ -0,0 +1,93 @@+import Foundation++/// What the import commit needs from an archive's Work record, over both formats+/// the app accepts.+///+/// The four other record kinds are literally the same types in 4/4 and 5/6+/// (Decision 12) — only the Work record changed shape — so the commit loop and+/// the materializers would otherwise be written twice for the sake of two type+/// columns. Stating the shared half as a protocol keeps one body and puts the+/// single genuine difference where a reader will look for it:+/// `applyTypeColumns`, which is the whole of Q35 on one side and one line on the+/// other.+///+/// Conforming the frozen 4/4 record is not a change to the format: a protocol+/// conformance adds no wire surface, and the mapping it declares is the one the+/// import already performed inline.+internal protocol ArchiveWorkRecord {+    var id: UUID { get }+    var displayTitle: String { get }+    var lastParsedTitle: String? { get }+    var siteHostname: String { get }+    var entryIDs: [UUID] { get }+    var urlIdentity: String? { get }+    var urlIdentityState: WorkURLIdentityState { get }+    var urlIdentityRuleID: UUID? { get }+    var urlIdentityRuleVersion: Int? { get }+    var workURL: String? { get }+    var genericNotes: String { get }+    var genreTags: [String] { get }+    var titleProvenance: TitleProvenance { get }+    var createdAt: Date { get }+    var modifiedAt: Date { get }++    /// Writes the record's type onto a row. The only thing the two formats do+    /// differently, and the only write here that reads the row before writing it.+    func applyTypeColumns(to work: Work)+}++extension ArchiveWorkRecord {+    /// The record as the shared reference checks see it: the fields a Work+    /// record is *checked* by, which are the same in both formats and none of+    /// which is a type column.+    ///+    /// Stated once here rather than per codec for the reason the protocol+    /// exists: two identical projections are two chances for one format's+    /// validation to stop checking something.+    internal var referenceRecord: BackupWireWorkReference {+        BackupWireWorkReference(+            id: id, siteHostname: siteHostname, entryIDs: entryIDs,+            urlIdentity: urlIdentity, urlIdentityState: urlIdentityState,+            urlIdentityRuleID: urlIdentityRuleID,+            urlIdentityRuleVersion: urlIdentityRuleVersion)+    }+}++extension BackupV4Work: ArchiveWorkRecord {+    /// Req 7.5 as Q35 scopes it.+    ///+    /// A 4/4 archive can say only what a pre-feature build could say, so its+    /// untyped record — the raw value `other` — is ambiguous in exactly one+    /// direction: it may be an archived untype, or it may be the compatibility+    /// value a configured or unrecognised assignment stores. Requirement 7.5+    /// forbids the second reading ("importing it SHALL NOT untype a work whose+    /// current type is not expressible in that format"), and nothing more: an+    /// archived untype of a *legacy-typed* or already untyped work is a+    /// legitimate pre-feature edit and applies under the commit's timestamp+    /// guard.+    ///+    /// Any other value is a legacy retype and is written whole — `typeRaw` set+    /// and `workTypeID` cleared — which is [6.10](../../../../specs/configurable-work-types/requirements.md#6.10)'s+    /// rule reached through an archive instead of through sync.+    func applyTypeColumns(to work: Work) {+        let archived = WorkTypeAssignment.assignment(typeRaw: type.rawValue, workTypeID: nil)+        if archived == .none {+            switch WorkTypeAssignment.assignment(of: work) {+            case .configured, .unrecognised: return+            case .none, .legacy: break+            }+        }+        WorkTypeWriter.apply(archived, to: work)+    }+}++extension BackupV5Work: ArchiveWorkRecord {+    /// The 5/6 record says what it means, so there is nothing to interpret: a+    /// configured identifier, a legacy raw value, or untyped, written through the+    /// one shared writer. An identifier the merged list still cannot resolve is+    /// written anyway and renders as unresolved (Q24) — refusing it would be+    /// worse, and fabricating a name would be inventing data.+    func applyTypeColumns(to work: Work) {+        WorkTypeWriter.apply(assignment, to: work)+    }+}
Asterism/Asterism/ViewModels/WorkDetailModel.swift Modified +60 / -6
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex b6a5c41..d58405a 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -44,7 +44,17 @@ public final class WorkDetailModel {      // Metadata draft fields     public var draftTitle: String = ""-    public var draftType: WorkType = .other+    /// The picker's selection, as an assignment (Req 3.1–3.3). Saving without+    /// touching it writes back exactly what the work carried, whatever kind of+    /// type that is.+    public var draftAssignment: WorkTypeAssignment = .none+    /// The rows the picker offers beside the blank one (Reqs 3.1, 3.3).+    ///+    /// Every active type, alphabetically, and — when the work carries something+    /// the list no longer offers — that type too, last. Stated as displays+    /// rather than assignments so a row has a name to show, and so the view can+    /// draw a removed or legacy row dimmed (Q23) without re-deriving anything.+    public private(set) var typeOptions: [WorkTypeDisplay] = []     public var draftTags: [String] = []     public var draftNotes: String = "" @@ -93,7 +103,8 @@ public final class WorkDetailModel {             presentation = detail             let snapshot = detail.work             draftTitle = snapshot.displayTitle-            draftType = snapshot.type+            draftAssignment = snapshot.typeDisplay.assignment+            typeOptions = await pickerOptions(carrying: snapshot.typeDisplay)             draftTags = snapshot.genreTags             draftNotes = snapshot.genericNotes             state = .ready@@ -104,6 +115,48 @@ public final class WorkDetailModel {         }     } +    /// The picker's rows (Reqs 3.1, 3.3, 1.6).+    ///+    /// The configured list first, alphabetically by the reader's locale, then —+    /// only when the list does not already offer it — whatever the work itself+    /// carries: a removed entry, a legacy value, or an assignment whose entry+    /// has not arrived (Req 8.6), which has no name for the view to show.+    ///+    /// A carried legacy value spelled like an active type is **not** the same+    /// row as that type (Decision 7): the assignments differ, so both are+    /// offered and choosing the configured one is a real change (Req 3.3).+    ///+    /// A failed list read is not a failed screen. The work opens with its own+    /// type still selectable, which is the row that matters most here — the one+    /// a save must be able to write back unchanged.+    private func pickerOptions(carrying carried: WorkTypeDisplay) async -> [WorkTypeDisplay] {+        var options: [WorkTypeDisplay] = []+        do {+            // The uncounted read (`workTypeOptions`), because the picker draws+            // names: Req 1.5's usage counting fetches every Work row, and this+            // read runs on every load of the editor.+            //+            // Sorted here by the list's own comparator (Q7, Q11) rather than+            // trusting the read's order: the ordering the reader sees is the+            // picker's statement, not a side effect of how a provider happened+            // to return its rows.+            options = try await library.workTypeOptions()+                .filter { $0.state == .active }+                .sorted(by: WorkTypeSnapshot.displayOrder)+                .map {+                    WorkTypeDisplay(+                        assignment: .configured($0.id), name: $0.name, kind: .active)+                }+        } catch {+            Self.logger.error(+                "Work types read failed: \(String(describing: error), privacy: .public)")+        }+        guard carried.kind != .none,+            !options.contains(where: { $0.assignment == carried.assignment })+        else { return options }+        return options + [carried]+    }+     public func loadWorkURL() async {         do {             let contract = try await library.projectWorkURL(workID: workID, request: .clear)@@ -211,7 +264,7 @@ public final class WorkDetailModel {     public var hasUnsavedChanges: Bool {         guard let work else { return false }         return draftTitle != work.displayTitle-            || draftType != work.type+            || draftAssignment != work.typeDisplay.assignment             || draftTags != work.genreTags             || draftNotes != work.genericNotes     }@@ -287,7 +340,7 @@ public final class WorkDetailModel {     private func restoreDraftsFromSnapshot() {         guard let work else { return }         draftTitle = work.displayTitle-        draftType = work.type+        draftAssignment = work.typeDisplay.assignment         draftTags = work.genreTags         draftNotes = work.genericNotes     }@@ -301,13 +354,14 @@ public final class WorkDetailModel {         do {             let draft = WorkMetadataDraft(                 displayTitle: draftTitle,-                type: draftType,+                typeAssignment: draftAssignment,                 genreTags: draftTags,                 genericNotes: draftNotes             )             let basis = work.map(WorkEditBasis.init(work:))                 ?? WorkEditBasis(-                    displayTitle: draftTitle, type: draftType, genreTags: draftTags,+                    displayTitle: draftTitle, typeAssignment: draftAssignment,+                    genreTags: draftTags,                     genericNotes: draftNotes, siteHostname: "", urlIdentity: nil,                     lastParsedTitle: nil, titleProvenance: .manual)             let outcome = try await library.updateWork(id: workID, basis: basis, draft: draft)
Asterism/Asterism/ViewModels/WorkTypesModels.swift Added +313 / -0
diff --git a/Asterism/Asterism/ViewModels/WorkTypesModels.swift b/Asterism/Asterism/ViewModels/WorkTypesModels.swiftnew file mode 100644index 0000000..dd042b3--- /dev/null+++ b/Asterism/Asterism/ViewModels/WorkTypesModels.swift@@ -0,0 +1,313 @@+import AsterismCore+import Foundation+import Observation+import OSLog++// The work-types settings screen's two models (Requirement 1).+//+// Modelled on `SitesModels.swift`, and for the same reason: every sentence the+// screen shows is built here, so the views choose rows and styling and never+// wording — which is also what makes the screen's language testable.+//+// The list is a *palette*, not an authority (Decision 2): removing a type takes+// it out of the picker and edits nothing, so this screen never writes to a work.++/// The configured list as settings presents it: the active types, and beneath+/// them the removed types works still carry (Reqs 1.1, 1.7).+@MainActor @Observable+public final class WorkTypesModel {+    private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "WorkTypesModel")++    public enum State: Equatable, Sendable {+        case loading+        case ready+        case error(message: String)+    }++    /// One entry, as the list presents it.+    public struct Row: Identifiable, Equatable, Sendable {+        public let type: WorkTypeSnapshot++        public var id: UUID { type.id }+        public var name: String { type.name }+        /// Req 1.7: the removed entries are shown, visually apart from the+        /// active ones — dimmed violet, per Q23.+        public var isRemoved: Bool { type.state == .removed }+        public var usageCount: Int { type.usageCount }++        /// How many works carry this type, or nil when there is nothing to say.+        ///+        /// Counted the way Req 1.5 counts: user-visible works, not duplicate+        /// rows and not legacy-typed works that merely share the spelling (Q14).+        /// The repository does the counting; this is the sentence for it.+        public var usageLine: String? {+            guard usageCount > 0 else { return nil }+            return "Used by \(Pluralisation.count(usageCount, "work", "works"))"+        }+    }++    public private(set) var state: State = .loading+    /// The active entries, in the order the read returned them — alphabetical by+    /// the reader's locale (Q7, Q11).+    public private(set) var rows: [Row] = []+    /// Req 1.7's second list: removed entries works still use. A removed entry+    /// nothing uses is not listed — there is nothing for the reader to do about+    /// it, and re-adding its name restores it either way (Q15).+    public private(set) var removedRows: [Row] = []++    /// The add field. Kept on a rejection so the reader can correct what they+    /// typed rather than type it again.+    public var draftName: String = ""+    /// The one line the screen says back: a rejection's reason (Req 1.2) or a+    /// restore's confirmation (Req 5.3). Nil when there is nothing to report.+    public private(set) var message: String?++    /// Req 1.6: an emptied list is a state the reader put the library in, so the+    /// screen says what it means rather than showing an empty box.+    public let emptyMessage =+        "No work types. Add one above and it joins the type picker when you edit a work; "+        + "until then, works can only be untyped."++    /// Why removed types are still on this screen (Req 1.7, Decision 2).+    public let removedExplanation =+        "These types were removed from the picker. The works using them keep the label — "+        + "adding the name again restores the type."++    public var canAdd: Bool {+        !WorkTypeName.trimmed(draftName).isEmpty && !isSubmitting+    }++    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private var isSubmitting = false++    public init(+        library: any LibraryProviding,+        onMutation: @escaping @Sendable () async -> Void+    ) {+        self.library = library+        self.onMutation = onMutation+    }++    public func load() async {+        state = .loading+        do {+            let types = try await library.workTypes().map(Row.init(type:))+            rows = types.filter { !$0.isRemoved }+            removedRows = types.filter(\.isRemoved)+            state = .ready+        } catch {+            rows = []+            removedRows = []+            state = .error(message: error.localizedDescription)+            Self.logger.error("Work types read failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// Adds the typed name — or restores the removed entry that already holds it+    /// (Reqs 1.2, 5.3).+    ///+    /// The two outcomes are worded differently on purpose: a restore hands works+    /// that kept the type their entry back, which "added" would quietly hide.+    public func add() async {+        guard canAdd else { return }+        isSubmitting = true+        defer { isSubmitting = false }+        let typed = draftName+        do {+            switch try await library.addWorkType(name: typed) {+            case .added:+                draftName = ""+                message = nil+                await committed()+            case .restored:+                draftName = ""+                message =+                    "“\(WorkTypeName.trimmed(typed))” was removed "+                    + "earlier. It is back in the list, and the works that kept it are using "+                    + "it again."+                await committed()+            case .rejected(let rejection):+                message = WorkTypeRejectionPresentation.sentence(for: rejection)+            }+        } catch {+            message = error.localizedDescription+            Self.logger.error("Work type add failed: \(String(describing: error), privacy: .public)")+        }+    }++    /// One type's screen. Built here rather than by the host: nothing about it+    /// needs navigation the way the Sites detail's re-teach route does, and the+    /// list is what has to re-read itself once the detail has written.+    public func detailModel(for row: Row) -> WorkTypeDetailModel {+        WorkTypeDetailModel(+            type: row.type,+            library: library,+            onMutation: onMutation,+            onChanged: { [weak self] in await self?.load() })+    }++    private func committed() async {+        await onMutation()+        await load()+    }+}++/// One type's screen: rename it, or remove it from the picker (Reqs 1.3–1.5).+@MainActor @Observable+public final class WorkTypeDetailModel {+    private static let logger = Logger(+        subsystem: "me.nore.ig.Asterism", category: "WorkTypeDetailModel")++    /// What the removal confirmation is offering.+    ///+    /// The count rides **on the presented value**, not beside it in the model,+    /// for the reason `SiteDetailModel.ArticlesPrompt` records: SwiftUI runs a+    /// dialog's `isPresented` setter — the dismissal — *before* it runs the+    /// tapped button's action, so a confirm that re-read a model property found+    /// it already cleared and committed nothing, silently.+    public struct RemovalPrompt: Identifiable, Equatable, Sendable {+        public let id: UUID+        public let name: String+        public let usageCount: Int++        /// Q12: the confirmation was kept because removal takes away a future+        /// choice, and reworded because it takes away nothing else. It must not+        /// imply data loss — there is none (Decision 2).+        public var message: String {+            guard usageCount > 0 else {+                return "Nothing uses this type. Removing it takes it out of the picker for "+                    + "future edits; adding the name again restores it."+            }+            let subject = Pluralisation.count(usageCount, "work uses", "works use")+            return "\(subject) this type. They keep the label — removing it only takes the "+                + "type out of the picker for future edits."+        }+    }++    public let type: WorkTypeSnapshot+    /// Opens on the stored spelling, because a rename is nearly always a+    /// correction of it (Q13).+    public var draftName: String+    public private(set) var removalPrompt: RemovalPrompt?+    public private(set) var errorMessage: String?+    public private(set) var isSubmitting = false+    /// Set once a write lands, so the screen can leave: it holds an immutable+    /// snapshot, and after a rename or a removal everything on it describes the+    /// type as it was (the `SiteDetailView` precedent).+    public private(set) var didFinish = false++    private let library: any LibraryProviding+    private let onMutation: @Sendable () async -> Void+    private let onChanged: @MainActor () async -> Void++    public init(+        type: WorkTypeSnapshot,+        library: any LibraryProviding,+        onMutation: @escaping @Sendable () async -> Void,+        onChanged: @escaping @MainActor () async -> Void+    ) {+        self.type = type+        self.draftName = type.name+        self.library = library+        self.onMutation = onMutation+        self.onChanged = onChanged+    }++    // MARK: - Wording++    /// Req 1.5's count, stated on the screen that offers the removal — so the+    /// reader knows it before the dialog tells them again.+    public var usageLine: String {+        guard type.usageCount > 0 else { return "No works use this type." }+        return "Used by \(Pluralisation.count(type.usageCount, "work", "works"))."+    }++    public var canRename: Bool {+        !WorkTypeName.trimmed(draftName).isEmpty && !isSubmitting+    }++    // MARK: - Rename (Reqs 1.3, 5.4)++    /// Renames the entry. Every work using it follows, because a work cites the+    /// identity and the identity carries the name (Decision 3) — nothing here+    /// writes to a work.+    public func rename() async {+        guard canRename else { return }+        isSubmitting = true+        errorMessage = nil+        defer { isSubmitting = false }+        do {+            switch try await library.renameWorkType(id: type.id, to: draftName) {+            case .added, .restored:+                await onMutation()+                await onChanged()+                didFinish = true+            case .rejected(let rejection):+                errorMessage = WorkTypeRejectionPresentation.sentence(for: rejection)+            }+        } catch {+            errorMessage = error.localizedDescription+            Self.logger.error(+                "Work type rename failed: \(String(describing: error), privacy: .public)")+        }+    }++    // MARK: - Removal (Reqs 1.4, 1.5, 5.1)++    public func requestRemoval() {+        guard !isSubmitting else { return }+        errorMessage = nil+        removalPrompt = RemovalPrompt(+            id: type.id, name: type.name, usageCount: type.usageCount)+    }++    /// The dialog's dismissal. It may clear the prompt freely: the confirm takes+    /// what it needs as a parameter, so nothing here can strand a commit that is+    /// about to run.+    public func cancelRemoval() {+        removalPrompt = nil+    }++    /// Commits the removal the dialog rendered. Nothing is deleted and no work+    /// is edited — the entry stays in the store so works keep displaying it and+    /// so restoring it is the same identity again (Decision 2, Decision 3).+    public func confirmRemoval(_ prompt: RemovalPrompt) async {+        guard !isSubmitting else { return }+        isSubmitting = true+        errorMessage = nil+        defer { isSubmitting = false }+        do {+            try await library.removeWorkType(id: prompt.id)+            removalPrompt = nil+            await onMutation()+            await onChanged()+            didFinish = true+        } catch {+            errorMessage = error.localizedDescription+            Self.logger.error(+                "Work type removal failed: \(String(describing: error), privacy: .public)")+        }+    }+}++/// The one place a refused name is turned into words, so the add field and the+/// rename field cannot explain the same refusal differently (Reqs 1.2, 5.4).+enum WorkTypeRejectionPresentation {+    static func sentence(for rejection: WorkTypeRejection) -> String {+        switch rejection {+        case .emptyName:+            "Enter a name for the type."+        case .invalidCharacters:+            "A type name is a single line of text, without line breaks or control characters."+        case .duplicateActive(let existing):+            "“\(existing)” is already in the list."+        case .collidesWithRemoved(let existing):+            // Req 5.4: renaming into a removed name would merge two identities,+            // which is out of scope (Q10) — so the refusal names the way through+            // rather than leaving the reader at a dead end.+            "“\(existing)” was removed earlier. Add that name in the list to restore it, "+                + "instead of renaming this type into it."+        }+    }+}
Asterism/Asterism/Views/WorkTypePresentation.swift Added +53 / -0
diff --git a/Asterism/Asterism/Views/WorkTypePresentation.swift b/Asterism/Asterism/Views/WorkTypePresentation.swiftnew file mode 100644index 0000000..4949773--- /dev/null+++ b/Asterism/Asterism/Views/WorkTypePresentation.swift@@ -0,0 +1,53 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// The one place a resolved work type is turned into a treatment, so the works+/// list, the work page, the picker and the resolution sheet cannot draw the same+/// type differently (configurable-work-types Req 3.4, Q23).+///+/// The rule is one sentence: a type the list still offers wears the surface's+/// usual violet; a type it no longer offers — removed, or a value written by a+/// build that had no list — wears the same violet knocked down, the way an+/// ignored teach chip is knocked down; and a state with nothing to name+/// (untyped, or an entry that has not arrived) draws nothing at all+/// (Reqs 3.2, 8.6).+enum WorkTypePresentation {++    /// The `ignored`-chip knock-down, reused rather than re-picked (Q23) — the+    /// design language's own constant, so a label and the pill beside it cannot+    /// be dimmed by different amounts.+    private static let knockdown = ConstellationRecipes.knockdownOpacity++    /// The pill a work's type wears, or nil where there is no pill to draw.+    static func pillKind(for kind: WorkTypeDisplay.Kind) -> ConstellationPillKind? {+        switch kind {+        case .active: .typeTag+        case .removed, .legacy: .dimmedTypeTag+        case .unresolved, .none: nil+        }+    }++    /// A type shown as a violet text label rather than a pill — the resolution+    /// sheet's variant lines.+    static func labelStyle(for kind: WorkTypeDisplay.Kind) -> Color {+        switch kind {+        case .active, .none: AsterismColors.violet+        case .removed, .legacy: AsterismColors.violet.opacity(knockdown)+        case .unresolved: AsterismColors.secondaryText+        }+    }++    /// A row in the editor's type menu, where the offered types are plain text+    /// like every other menu row and only the carried-but-not-offered one is+    /// singled out (Req 3.3).+    static func menuRowStyle(for kind: WorkTypeDisplay.Kind) -> Color {+        switch kind {+        case .active, .none: AsterismColors.primaryText+        case .removed, .legacy: AsterismColors.violet.opacity(knockdown)+        // A placeholder for a name that has not arrived is not a type the+        // reader can read anything into.+        case .unresolved: AsterismColors.secondaryText+        }+    }+}
… plus 154 further files (tests, fixtures, UI, spec documents) — see the commit list; full diff is f9f5943..HEAD Summary +12,369 / -1,330 total
(no diff provided)

Things to double-check

Q54 device check before release.

The pre-feature-build CloudKit coexistence check is the one open gate: a pre-feature build and this build against the same container, physical-device work needing explicit approval at the moment it runs. Procedure in specs/configurable-work-types/verification-run.md. It blocks release, not this push.

BootstrapClassifierTests digest flake rate.

The known cross-suite SQLite-checkpoint flake fired in 2 of 3 post-phase full swift test runs (never in 5 isolated runs). If it keeps firing at this rate, harden the digest comparison to checkpoint (or exclude the WAL) before hashing — recorded in verification-run.md.

Req 8.8 direct test.

A one-case test that a share-extension capture produces an untyped work (typeRaw == "other", workTypeID == nil) would close the last indirect verification; currently it holds by model defaults.

Dev-container rehearsal already underway.

The Development build was installed on the owner's device after task 21; its first open establishes the new record type in the dev CloudKit container. A pre-feature dev build on a second device against the same account is the safe rehearsal of the Q54 check.