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.
typeRaw column; the precisely-known blind spots are recorded (Q17/Q21/Q28), not hidden.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.
d04c6e8 T-2076: configurable-work-types spec (requirements, design, tasks, decision log) 36a0218 T-2076: schema V6 and the work-type foundations 9e4faea T-2076: address phase 1 review findings 5a4203a T-2076: changelog for phase 1 (Schema and Foundations) 4137f9b T-2076: mark configurable-work-types In Progress in specs overview 15ff0e1 T-2076: the 5/6 backup format and its exporter 3d7557b T-2076: bootstrap and plumbing — marker "6", work-type seeding, sixth-entity counts 3d8b9fc [merge]: bootstrap-plumbing stream 1 ae89baf [merge]: backup-format stream 2 d11f28b T-2076: address phase 2 review findings e296517 T-2076: changelog for phase 2 (Bootstrap and Plumbing, Backup Format 5/6) df0a0db T-2076: repository surfaces for configurable work types 41e65aa T-2076: record phase 3 review decisions Q46-Q49 859c2fb T-2076: rename to the current spelling writes nothing d5c2fc1 T-2076: changelog for phase 3 (Repository Surfaces) 970bb88 T-2076: backup import accepts 5/6 and scopes the 4/4 type mapping 9db751d T-2076: the work-types settings screen, the editor's picker, and their journeys 71bdefa [merge]: backup-import stream 2 0a6212a [merge]: app-ui stream 3 ba47ab5 T-2076: changelog for phase 4 (backup import, App UI) and review decisions Q52-Q53 33b2a7c T-2076: full-suite verification, and the coexistence gap it found c3f409d T-2076: changelog for phase 5 (Verification), spec marked Done, flake observation fd2de48 T-2076: apply pre-push review fixes 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.
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.
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.
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.
Site.entries fan-out problem; costs tolerance of unresolved ids everywhere (they render unresolved and heal).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.
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.
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).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
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
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
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
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)
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
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
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.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.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.| Severity | Area | Finding | Resolution |
|---|---|---|---|
| major | WorkDetailModel picker | pickerOptions 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. |
| minor | LibraryRepository+Export | entryExportInput 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. |
| minor | BackupV5Codec | sha256Hex, 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. |
| minor | BackupV5Exporter | ~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). |
| minor | WorkDetailModel comparator | pickerOrder 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. |
| minor | LibraryDiagnostics | workTypeCollisions hand-rolled the collision grouping that WorkTypeReconciler.collisions(in:) states once (and which import already reuses). | Now calls WorkTypeReconciler.collisions(in:). |
| minor | docs/agent-notes/testing.md | The 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. |
| minor | design.md / decision log | Two bookkeeping gaps: a stale 'FrozenLibraryPathTests unchanged' sentence, and the recordNotFound error-shape deviation unrecorded. | Design sentence amended; Q55 quick row added. |
| nit | ConstellationKit / WorkTypePresentation | The Q23 knock-down opacity 0.5 was declared in three places across two modules. | One public ConstellationRecipes.knockdownOpacity constant, referenced from all three sites. |
| nit | WorkTypesModels | Three inline whitespace trims where WorkTypeName.trimmed is the purpose-built spelling. | Replaced at all three sites. |
| nit | BackupV4/V5Codec | Identical referenceRecord bodies on both wire Work records. | entryIDs added to the ArchiveWorkRecord protocol; one protocol-extension property replaces both copies. |
| minor | WorkTypeDirectory / WorkTypeReconciler | Two 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. |
| nit | LibraryRepository+Bootstrap | Three 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. |
| nit | BackupImportWorkTypes | The 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. |
| nit | Req 8.8 coverage | No 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. |
Click to expand.
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)]+ }+}
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+ }+ }+}
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+ }+ }+}
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+ }+}
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()+ }+ }+}
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)+ }+}
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))+ }+ }+}
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 }
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")+ }+ }+}
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)+ }+}
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() }+ }+}
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)+ }+}
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)
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."+ }+ }+}
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+ }+ }+}
(no diff provided)
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.
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.
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.
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.