asterism branch T-1916/share-sheet-characters commits 17 files 16 code/doc (+5 spec) lines +1,165 / −25

Pre-push review: T-1916/share-sheet-characters

One read-only Characters: Alice (Al, Ally), Bob row on both share-extension capture sheets, whenever the share resolves to a work the library already holds. Five implementation phases plus one review-fix commit.

At a glance

  • Two arms, two reads. The re-share sheet gets the cast inside the lookup's existing locked read (opt-in flag the drain never sets); the new-capture sheet reads once per projected work id, never per keystroke.
  • One order. sortedCharacterGroups is shared by the work page and the share row; a test asserts parity with workDetail(id:).
  • Failure-silent. A failed read on either arm leaves the state a characterless work produces and logs the cause at debug.
  • Review fix. displayedCharacters guarantees the row never shows a previous work's cast; loadCharactersIfNeeded now reports whether it published so the bridge owns no copy of the guard.
  • No schema, archive, CloudKit, or auth change. Extension still links AsterismCore only.

Verdict

Ready to push

Four review agents (reuse, quality, efficiency, spec) raised 17 findings; one was a real defect — the new-capture sheet could show work A's cast under work B's title for the duration of one read — and it is fixed by moving the publish rule into CaptureViewModel.displayedCharacters where CaptureStateTests covers it. Eight findings were fixed in 6238fef; the rest were skipped as taste or as second code paths not worth their maintenance cost. After the fixes: make test-core 1,933 cases green (incl. verify-identity), make test-quick green, Asterism Development builds with only the known pre-existing warning. Every MUST in the smolspec is implemented and every departure is in the decision log (Q16–Q24).

Review findings

14 raised · 8 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What changed

When you share a chapter into Asterism, the note sheet now shows one extra line — Characters: Alice (Al, Ally), Bob — listing every character the app has recorded for that story, with nicknames in brackets, in the same order the story's page uses. It is read-only and disappears entirely when the story has no characters or the lookup fails. The same line appears on the “Noted …” re-share sheet.

Why it matters

A reader writing a chapter note half-way through a long serial no longer has to leave the share sheet to remember who a character is. The app already extracted the cast from earlier notes; now it is right above the text box.

Key concepts

  • Share extension — the small, tightly budgeted part of Asterism that runs inside Safari's share menu and can only use the AsterismCore library.
  • Projected work — the sheet's live guess, as you type a title, of which story the share belongs to: an existing one, a new one, or ambiguous.
  • Torn character — a character whose stored rows disagree after edits on two devices; the sheet shows whichever version the app's own page shows.
  • Shared lock — an “I am only reading” lock that several readers can hold together and that never blocks a save.

Changes overview

  • Projection (ShareCharacters.swift, WorkCharacterPresentation.swift): ShareCharacter, ShareCharacterRow.text(for:), sortedCharacterGroups, and LibraryRepository.shareCharacters(workID:) as one shared-lock read by #Predicate (unknown id → [], not recordNotFound).
  • Re-share arm: captureLookup(rawURL:captureTitle:includeCharacters:) attaches the cast to an .edit basis inside the lookup's own locked read; ReShareEditBasis.charactersReShareEditState.characters; .stale carries the list forward.
  • New-capture arm: CaptureCoordinating.shareCharacters(workID:); CaptureViewModel.loadCharactersIfNeeded keyed on projectedWorkID with charactersWorkID/inFlightWorkID; displayedCharacters and needsCharacterLoad for the bridge.
  • Views: one ShareCharactersRow used by both sheets; ObservableCaptureViewModel.refreshState() spawns the read.

Implementation approach

The re-share lookup already holds the matched entry's work under the lock, so the cast rides on the basis (Q5, Q8) behind an includeCharacters flag only CaptureCoordinator sets — PendingCaptureDrain runs the same lookup per spooled record and discards everything but the entry id (Q10). The flag is a separate overload because a protocol witness must match the requirement's full name (Q16). The new-capture projection is recomputed per keystroke and compared at commit, so the cast cannot live on CaptureOutcome; a follow-up read keyed by work id runs once per work (Q12). refreshState() is the choke point every awaited call passes through, so it triggers the read (Q13), publishes displayedCharacters (Q22), and refreshes again only when the read reports it published (Q23).

Trade-offs

  • Full list, names and aliases only (Q1, Q2) — no prominence field to rank by; facts may cite chapters ahead.
  • Narrow read over workDetail(id:) (Q4); still pays the facts JSON round-trip per character. A single-row fast path was rejected in review as a second code path that must stay byte-identical with the work page.
  • Row blanks during a work change (Q22) rather than showing the previous cast under the new title.

Technical deep dive

captureLookup derives entryGroups once and passes them to lookupDisposition(fromGroups:) (Q24); the from: [Entry] overload forwards for the commit race guard. .edit is returned only when groups.count == 1, so .values.first?.carrier.work?.id is exact. The character read runs on the same ModelContext; a throw is caught, logged at debug with the cause, and the unadorned disposition returned (Q17).

loadCharactersIfNeeded (@MainActor, returns Bool): guard needsCharacterLoad; nil target → clear all three fields, return whether anything was set (Q18: nil check first because the smolspec's literal order never reached the clear); else set inFlightWorkID, await, release the marker only if it is still this read's (Q19), publish only if the projection still names the read work, return whether the pair changed. displayedCharacters is charactersWorkID == currentOutcome?.projectedWorkID ? characters : [].

Interleavings checked: A outstanding → B → A (B spawn early-returns on the in-flight marker); A outstanding → nil → A (two A reads, both publish the same list — redundant, never wrong); two refreshState() before the first task body runs (second spawn absorbed by the model's guard).

Architecture impact

Public surface grows in AsterismCore only; all consumers are in-repo; nothing persisted or synced changes. ReShareEditBasis.characters is valid only on a lookup-produced basis — the commit path's bases never carry one — and the field says so. ShareCharactersRow is the first shared row view in the extension; the other five metadata rows still open-code the shape.

Potential issues

  • Large casts pay one facts JSON decode/encode per character under the shared lock; fine at sheet scale.
  • A stale DerivedData/Build/Products/Development-iphonesimulator/AsterismCore.swiftmodule shadowed the fresh package framework during review and reported displayedCharacters missing; deleting it fixed the build.
  • The bridge is verified by build only (no extension UI tests by decision); the publish rule and spawn predicate were moved into the model so tests cover them.
  • No test asserts CaptureCoordinator passes includeCharacters: true; ExtensionLookupWiringTests is the precedent if wanted.

Important changes — detailed

CaptureViewModel: displayedCharacters and a Bool-returning loadCharactersIfNeeded

CaptureViewModel.swift

Why it matters. This is where the one real defect lived and where the concurrency story is decided. The bridge used to copy the stored list before the read ran, so a work change could show the previous work's cast under the new title; displayedCharacters gates on the projected id instead. The Bool return lets the bridge refresh only when something changed, which is what removes the spin Q21 worked around.

What to look at. CaptureViewModel.swift: characters / charactersWorkID / inFlightWorkID, displayedCharacters, needsCharacterLoad, loadCharactersIfNeeded(coordinator:)

Takeaway. When a SwiftUI bridge copies view-model state on every refresh, derive the published value from the model's invariant (id matches → list, else empty) rather than trusting the stored value to be current. And have async loaders report whether they published so the caller can skip a redundant redraw.
Rationale. Q12 (work-id guard, not generation), Q18 (nil check before the id guards — the smolspec's order never reached the clear), Q19 (release the in-flight marker only if it is still this read's), Q22 (publish rule in the model, where CaptureStateTests covers it), Q23 (single predicate owned by the model).

LibraryRepository+Capture: captureLookup(includeCharacters:) inside the existing locked read

LibraryRepository+Capture.swift

Why it matters. The re-share arm pays one lock acquisition for disposition and cast together; the drain, which runs the same lookup for every spooled record, never pays for the character walk. The shape of the flag is dictated by a Swift protocol-witness rule.

What to look at. LibraryRepository+Capture.swift: ReShareEditBasis.characters + with(characters:), captureLookup(rawURL:captureTitle:includeCharacters:), lookupDisposition(fromGroups:)

Takeaway. A defaulted parameter cannot satisfy a protocol requirement spelled without it — keep the protocol-facing signature as its own method forwarding to the wider one. And when a helper computes something a caller also needs, split the helper so the caller can pass it in rather than recomputing it.
Rationale. Q5 (one read on the re-share arm), Q8 (display-only field on the basis), Q10 (opt-in so the drain skips the JSON canonicalisation), Q16 (overload, not default argument), Q17 (do/catch so the cause is logged), Q24 (entryGroups once).

ShareCharacters: the projection and the shared-lock read

ShareCharacters.swift

Why it matters. Defines the value both sheets draw and the only new repository read. Fetching Work rows by predicate rather than through fetchWorkGroup makes an unknown id an empty cast instead of an error and skips loading the work-type directory.

What to look at. ShareCharacters.swift: ShareCharacter, ShareCharacterRow.text(for:), shareCharacters(forWorkID:context:), shareCharacters(workID:)

Takeaway. Derive display text in the core module as a testable pure function and let the view lay it out; a nil return for 'nothing to show' keeps the empty case and the failed case one state.
Rationale. Q4 (narrow read over workDetail), Q6 (one text row, text decided in AsterismCore), Q7 (torn via presentedContent, orphans invisible).

WorkCharacterPresentation: sortedCharacterGroups shared with the work page

WorkCharacterPresentation.swift

Why it matters. The only change to code the work page runs. Sorting groups instead of presentations lets the share row reuse the comparator; the review moved the normalisation out of the comparator so it runs once per group.

What to look at. WorkCharacterPresentation.swift: sortedCharacterGroups(_:), characterPresentations

Takeaway. Sort the input type both consumers share, not the output type of one of them. Decorate–sort–undecorate when the key is expensive to compute.
Rationale. Q9. The decorate-sort-undecorate split into typed steps was an efficiency-review fix; the single-expression form hit the type-checker's time limit.

CaptureView / ShareCharactersRow: the bridge and the one row view

CaptureView.swift

Why it matters. The bridge is the only untested surface (no extension UI tests by decision). After the review it holds a copy of displayedCharacters, a guard on needsCharacterLoad, and one Task — nothing the model does not already own. ShareCharactersRow replaces two verbatim rows.

What to look at. CaptureView.swift: ObservableCaptureViewModel.refreshState(), scheduleCharacterLoad(); ShareCharactersRow.swift

Takeaway. Keep an ObservableObject bridge to a copy-and-spawn; any rule it enforces is a rule the tests cannot see.
Rationale. Q13 (trigger from refreshState, the choke point), Q20 (firstTextBaseline + fixedSize for a wrapping row), Q21→Q23 (guard moved into the model).

Key decisions

Full list, names and aliases only.

No prominence field exists to rank by (Q1); facts may cite chapters ahead of the one being shared and V7 encodes no spoiler boundary (Q2).

Two reads, one helper.

Re-share rides on captureLookup behind includeCharacters; new-capture uses a narrow shareCharacters(workID:), never workDetail (Q4, Q5, Q10).

Overload instead of default argument.

A protocol witness must spell the requirement's full name, so captureLookup(rawURL:captureTitle:) stays as a forwarding method (Q16).

Nil check before the id guards.

The smolspec's literal order returned early on target == inFlightWorkID (both nil) and never cleared the row (Q18).

In-flight marker released by its owner only.

if inFlightWorkID == target { inFlightWorkID = nil } — a superseded read neither blocks a later read nor clobbers a newer marker; the X→Y→X double publish is accepted (Q19).

displayedCharacters gates the bridge's copy.

The bridge copied characters before the read ran; a work change could show A's cast under B's title. The rule now lives in the model where CaptureStateTests covers it (Q22).

loadCharactersIfNeeded returns whether it published.

A no-op call no longer refreshes, so the spin Q21 guarded against cannot occur; needsCharacterLoad is the single predicate and the bridge keeps no copy (Q23).

entryGroups derived once per lookup.

lookupDisposition(fromGroups:) serves both the disposition and the cast; the from: [Entry] overload remains for the commit race guard (Q24).

Single-row JSON fast path rejected.

The efficiency review proposed bypassing characterGroups for single-row buckets to skip the facts JSON round-trip. Rejected in this review: a second code path that must stay byte-identical with the work page, for a cost that is small at sheet scale.

Protocol-extension default for shareCharacters kept.

The quality review noted both conformers implement it, so the default only hides a forgotten override. Kept because the smolspec specifies it; revisit if a third conformer appears.

Review findings

SeverityAreaFindingResolution
majorCaptureViewModel / bridgeProjection moving from work A to work B (or to .create/.ambiguous/nil) kept A's cast on the sheet until B's read landed — the bridge copied the stored list before the read ran.displayedCharacters (list only while charactersWorkID matches the projected id); bridge publishes it; two new CaptureStateTests cases.
majorCaptureView.scheduleCharacterLoadThe bridge re-derived the model's 'needs a read' predicate across the module boundary, which is why two work-id fields were public.needsCharacterLoad on the model; loadCharactersIfNeeded returns Bool and the bridge refreshes only on true; id fields dropped to internal.
minorLibraryRepository+Capture.captureLookupentryGroups computed twice per opted-in .edit lookup, under the lock.lookupDisposition(fromGroups:) overload; groups derived once.
minorCaptureView / ReShareCaptureViewTwo verbatim copies of the characters row.ShareCharactersRow view in the extension target.
minorCaptureViewModel.loadCharactersIfNeededtry? swallowed the read failure without the debug log Q15/Q17 require on the re-share arm.do/catch with a per-file logger (captureLogger is file-private elsewhere).
minorWorkCharacterPresentation.sortedCharacterGroupsCharacterNameKey.normalize and presentedContent recomputed inside the comparator, O(n log n).Decorate–sort–undecorate, split into typed steps for the type-checker.
minordocsdocs/asterism-design.md §3.4/§3.5 did not list the row; ReShareEditBasis.characters validity undocumented; CHANGELOG '1930' vs sibling '1,904'.All three updated.
nitCaptureStateTests fakeUnsynchronised write to projectedWorkID inside the hook while the read was locked.Locked computed property over a private backing field.
minorShareCharacters.shareCharacters(forWorkID:)Each character pays a facts JSON decode/encode in characterGroups for a projection that reads only name and aliases.Skipped: a single-row fast path is a second code path that must stay byte-identical with the work page; cost is small at sheet scale.
minorCaptureView / ReShareCaptureView bodyShareCharacterRow.text(for:) rebuilt in body on every keystroke.Skipped: joining a handful of strings is negligible against the body invalidation that already happens.
minorCaptureCoordinatingProtocol-extension default for shareCharacters is dead (both conformers implement it) and hides a forgotten override.Skipped: smolspec specifies the default.
nitReShareEditBasis.with(characters:)Eleven-line respell exists only because characters is a let.Skipped: taste; the explicit init makes an omission a compile error.
nitCaptureStateTestsmakeContract respells CaptureStateFixture.captureBasis.Skipped: test-only tidy-up.
nitExtensionLookupWiringTestsNo test asserts CaptureCoordinator passes includeCharacters: true.Skipped: optional follow-up; the precedent file is named in implementation.md.

Per-file diffs

Click to expand.

Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift Added +81 / −0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swiftnew file mode 100644index 0000000..d4b87c9--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareCharacters.swift@@ -0,0 +1,81 @@+import Foundation+import SwiftData++// The cast of a work, as the share extension's capture sheets show it (T-1916).+//+// Derived here rather than in the extension for the reason sharesheet-polish Q6+// records for every other row of that card: the extension lays text out, it does+// not decide what the text says. It also keeps the order one answer — the share+// row and the work page sort the same groups through `sortedCharacterGroups`.++/// One character of a work on a capture sheet: what to call them, and what else+/// they are called.+///+/// Name and aliases only (Q2). A fact cites an entry anywhere in the work,+/// including chapters ahead of the one being shared, and the sheet has no+/// spoiler boundary to keep them behind.+public struct ShareCharacter: Sendable, Equatable {+    public let name: String+    /// In the order `CharacterAuthoredContent` holds them, which is sorted at+    /// construction — the order the work page shows too.+    public let aliases: [String]++    public init(name: String, aliases: [String] = []) {+        self.name = name+        self.aliases = aliases+    }+}++/// The single wrapping text row both capture sheets draw for a cast (Q6).+public enum ShareCharacterRow {++    /// `Characters: Alice (Al, Ally), Bob`, or nil where there are none.+    ///+    /// Nil rather than an empty string: a work without characters draws no+    /// element at all, so the sheet's state for it is the state for a work whose+    /// read failed.+    public static func text(for characters: [ShareCharacter]) -> String? {+        guard !characters.isEmpty else { return nil }+        let listed = characters.map { character in+            character.aliases.isEmpty+                ? character.name+                : "\(character.name) (\(character.aliases.joined(separator: ", ")))"+        }+        return "Characters: " + listed.joined(separator: ", ")+    }+}++// MARK: - The read++extension LibraryRepository {++    /// A work's characters for a capture sheet, in the work page's order.+    ///+    /// By predicate rather than through `fetchWorkGroup(id:)`: an id the sheet+    /// projected but the store does not hold is an empty cast rather than an+    /// error, and the group read loads the work type directory, which a name and+    /// its aliases need nothing from. Same-UUID duplicate rows are covered+    /// exactly as the work page covers them — `characterRows(of:)` unions the+    /// rows' inverses — and an orphaned character, unreachable through any of+    /// them, is invisible here as it is there (Q7).+    internal static func shareCharacters(+        forWorkID id: UUID, context: ModelContext+    ) throws -> [ShareCharacter] {+        let works = try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+        guard !works.isEmpty else { return [] }+        return sortedCharacterGroups(characterGroups(characterRows(of: works)))+            .map { group in+                let content = group.presentedContent+                return ShareCharacter(name: content.name, aliases: content.aliases)+            }+    }++    /// The same, as one shared-lock read. Never writes; an unknown id and a work+    /// without characters both answer `[]`.+    public func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {+        try await withLockedContext(mode: .shared, operation: "reading share characters") {+            context in+            try Self.shareCharacters(forWorkID: workID, context: context)+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift Modified +23 / −7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swiftindex c5a80b6..f4655b7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift@@ -107,6 +107,28 @@ public struct WorkCharacterPresentation: Identifiable, Sendable, Equatable {  extension LibraryRepository { +    /// The one order a work's characters are listed in, wherever they are listed+    /// (Q9): by the presented name normalised, UUID as the tie-break so two+    /// devices draw one list.+    ///+    /// Sorting the *groups* rather than the presentations is what lets the share+    /// sheet share it — the share row carries no facts, so it has no+    /// `WorkCharacterPresentation` to sort.+    internal static func sortedCharacterGroups(+        _ groups: [UUID: CharacterGroup]+    ) -> [CharacterGroup] {+        // The key is normalised once per group rather than inside the+        // comparator, which sees each group as many times as the sort compares it.+        let keyed: [(key: String, group: CharacterGroup)] = groups.values.map { group in+            (key: CharacterNameKey.normalize(group.presentedContent.name), group: group)+        }+        return keyed.sorted { left, right in+            left.key == right.key+                ? left.group.id.uuidString < right.group.id.uuidString+                : left.key < right.key+        }.map(\.group)+    }+     /// The work's characters as the page draws them, in name order.     ///     /// `captureOrder` maps a live entry's UUID to its position oldest-first,@@ -120,7 +142,7 @@ extension LibraryRepository {         dates: [UUID: Date] = [:],         keys: [UUID: ChapterKey] = [:]     ) -> [WorkCharacterPresentation] {-        groups.values+        sortedCharacterGroups(groups)             .map { group in                 let content = group.presentedContent                 return WorkCharacterPresentation(@@ -136,12 +158,6 @@ extension LibraryRepository {                     rowCount: group.rows.count,                     editBasis: CharacterEditBasis(characterID: group.id, content: content))             }-            // Name order, UUID as the tie-break so two devices draw one list.-            .sorted {-                let left = CharacterNameKey.normalize($0.name)-                let right = CharacterNameKey.normalize($1.name)-                return left == right ? $0.id.uuidString < $1.id.uuidString : left < right-            }     }      /// Q88's display order, and the citation each fact resolves to.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift Modified +82 / −7
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swiftindex 4f2345e..40ab915 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Capture.swift@@ -55,6 +55,13 @@ public struct ReShareEditBasis: Sendable, Equatable {     public let persistedModifiedAt: Date     /// Immutable first-capture time for banner formatting (Req 4.2).     public let firstCapturedAt: Date+    /// The cast of the work this entry belongs to, for the sheet's characters+    /// row (T-1916, Q8). Display-only, like `title` and `firstCapturedAt`: the+    /// staleness comparison on commit does not look at it. Empty unless the+    /// lookup was asked for it, and empty whenever the read failed (Q15).+    /// Only a lookup-produced basis can carry one: the commit path's bases —+    /// the pre-insert race guard's and `.stale`'s — walk no characters (Q11).+    public let characters: [ShareCharacter]      public init(         entryID: UUID,@@ -64,7 +71,8 @@ public struct ReShareEditBasis: Sendable, Equatable {         persistedNote: String,         persistedRating: Rating?,         persistedModifiedAt: Date,-        firstCapturedAt: Date+        firstCapturedAt: Date,+        characters: [ShareCharacter] = []     ) {         self.entryID = entryID         self.hostname = hostname@@ -74,6 +82,24 @@ public struct ReShareEditBasis: Sendable, Equatable {         self.persistedRating = persistedRating         self.persistedModifiedAt = persistedModifiedAt         self.firstCapturedAt = firstCapturedAt+        self.characters = characters+    }++    /// The same basis carrying a cast. The lookup builds the basis before it+    /// knows whether a character read is wanted, so the cast is attached rather+    /// than threaded through `reShareBasis(hostname:)`, which the commit path+    /// also calls and must keep answering without one.+    public func with(characters: [ShareCharacter]) -> ReShareEditBasis {+        ReShareEditBasis(+            entryID: entryID,+            hostname: hostname,+            identityKey: identityKey,+            title: title,+            persistedNote: persistedNote,+            persistedRating: persistedRating,+            persistedModifiedAt: persistedModifiedAt,+            firstCapturedAt: firstCapturedAt,+            characters: characters)     } } @@ -137,9 +163,27 @@ extension LibraryRepository {         try await captureLookup(rawURL: rawURL, captureTitle: nil)     } +    /// Protocol-facing lookup without a character read. Kept as its own method+    /// rather than a default argument because a protocol witness has to spell+    /// the requirement's full name.     public func captureLookup(         rawURL: String,         captureTitle: String?+    ) async throws -> CaptureLookupDisposition {+        try await captureLookup(+            rawURL: rawURL, captureTitle: captureTitle, includeCharacters: false)+    }++    /// - Parameter includeCharacters: Whether an `.edit` basis carries the+    ///   matched work's cast (T-1916). Opt-in because only the extension's+    ///   re-share sheet displays it: the pending-capture drain runs the same+    ///   lookup for every spooled record and reads nothing but the entry id+    ///   and the identity key from it, and the character walk canonicalises+    ///   each row's facts through JSON on the way (Q10).+    public func captureLookup(+        rawURL: String,+        captureTitle: String?,+        includeCharacters: Bool     ) async throws -> CaptureLookupDisposition {         let hostname: String         do {@@ -206,10 +250,32 @@ extension LibraryRepository {             )             let matches = try context.fetch(descriptor)             captureLogger.debug("Capture lookup: \(matches.count) match(es)")-            return Self.lookupDisposition(-                from: matches, hostname: hostname, identityKey: rawURL,-                canonicalWorkIDs: try Self.canonicalWorkIDs(-                    normalising: matches, context: context))+            let canonicalWorkIDs = try Self.canonicalWorkIDs(+                normalising: matches, context: context)+            let groups = Self.entryGroups(matches, canonicalWorkIDs: canonicalWorkIDs)+            let disposition = Self.lookupDisposition(+                fromGroups: groups, hostname: hostname, identityKey: rawURL)++            // Still inside the same shared read: the cast the sheet shows comes+            // from the state the disposition was decided on, over the one+            // `entryGroups` pass the disposition was decided from (Q24).+            // `.values.first` is the group the basis describes: `.edit` is+            // returned only where `groups.count == 1`.+            guard includeCharacters, case .edit(let basis) = disposition,+                  let workID = groups.values.first?.carrier.work?.id+            else { return disposition }++            do {+                return .edit(basis.with(+                    characters: try Self.shareCharacters(forWorkID: workID, context: context)))+            } catch {+                // The row is the reader's convenience, not their capture: a+                // failed read leaves the sheet exactly as a characterless work+                // leaves it, and says so in diagnostics only (Q15).+                captureLogger.debug(+                    "Capture lookup: character read failed — \(String(describing: error), privacy: .public)")+                return disposition+            }         }     } @@ -228,8 +294,17 @@ extension LibraryRepository {         from matches: [Entry], hostname: String, identityKey: String,         canonicalWorkIDs: [UUID: UUID]     ) -> CaptureLookupDisposition {-        let groups = LibraryRepository.entryGroups(-            matches, canonicalWorkIDs: canonicalWorkIDs)+        lookupDisposition(+            fromGroups: LibraryRepository.entryGroups(+                matches, canonicalWorkIDs: canonicalWorkIDs),+            hostname: hostname, identityKey: identityKey)+    }++    /// The same decision over groups a caller already derived, so a lookup that+    /// needs the group for itself pays for one `entryGroups` pass (Q24).+    static func lookupDisposition(+        fromGroups groups: [UUID: EntryGroup], hostname: String, identityKey: String+    ) -> CaptureLookupDisposition {         guard groups.count == 1, let group = groups.values.first, !group.isTorn else {             return .new(NewLookupBasis(hostname: hostname, identityKey: identityKey))         }
Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift Modified +14 / −3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swiftindex 2b1283b..5a06e94 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LookupCaptureViewModel.swift@@ -26,6 +26,10 @@ public struct ReShareEditState: Sendable, Equatable {     public let persistedNote: String     public let persistedRating: Rating?     public let firstCapturedAt: Date+    /// The cast of the work this entry belongs to (T-1916). Empty where the work+    /// has none, where the read failed, or where the lookup was never asked —+    /// all three draw the same sheet.+    public let characters: [ShareCharacter]     public var draftNote: String     public var draftRating: Rating?     public var cursorAtEnd: Bool@@ -40,7 +44,8 @@ public struct ReShareEditState: Sendable, Equatable {         draftNote: String,         draftRating: Rating?,         cursorAtEnd: Bool = true,-        errorMessage: String? = nil+        errorMessage: String? = nil,+        characters: [ShareCharacter] = []     ) {         self.entryID = entryID         self.title = title@@ -51,6 +56,7 @@ public struct ReShareEditState: Sendable, Equatable {         self.draftRating = draftRating         self.cursorAtEnd = cursorAtEnd         self.errorMessage = errorMessage+        self.characters = characters     } } @@ -170,7 +176,8 @@ public final class LookupCaptureViewModel {                 draftNote: basis.persistedNote,                 draftRating: basis.persistedRating,                 cursorAtEnd: true,-                errorMessage: nil+                errorMessage: nil,+                characters: basis.characters             ))          case .new(let basis):@@ -250,7 +257,11 @@ public final class LookupCaptureViewModel {                 draftNote: draftNote,                 draftRating: draftRating,                 cursorAtEnd: false,-                errorMessage: nil+                errorMessage: nil,+                // The refreshed basis comes from the commit path, which walks no+                // characters (Q11): the cast the reader is looking at carries+                // forward rather than blinking out between two taps of Update.+                characters: editState.characters             ))          case .invalidated(let reason):
Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift Modified +11 / −0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex 338ac53..b131b5e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -352,4 +352,15 @@ public protocol CaptureCoordinating: Sendable {     func projectCapture(hostname: String, captureTitle: String, captureTitleSource: CaptureTitleSource, rawURLString: String, canonicalURLString: String?, note: String, rating: Rating?) async throws -> CaptureContract     /// Commit an approved capture contract.     func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome+    /// The cast of the work the sheet projects, for the characters row (T-1916).+    /// A requirement rather than an extension-only method so a call through+    /// `any CaptureCoordinating` reaches `CaptureCoordinator`'s forwarding read;+    /// the default below keeps every existing conformer compiling.+    func shareCharacters(workID: UUID) async throws -> [ShareCharacter]+}++public extension CaptureCoordinating {+    /// No cast unless the conformer reads one — a coordinator that never talks to+    /// a library shows no characters rather than failing.+    func shareCharacters(workID: UUID) async throws -> [ShareCharacter] { [] } }
Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift Modified +13 / −1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swiftindex 6706dbd..0d4790e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareTransport.swift@@ -337,14 +337,26 @@ public actor CaptureCoordinator: CaptureCoordinating, LookupCaptureCoordinating             CaptureContract(basis: contract.basis, request: stamped, outcome: contract.outcome))     } +    /// The projected work's cast for the capture sheet's characters row (T-1916).+    /// A follow-up shared-lock read rather than part of the projection: the+    /// projection is recomputed per keystroke and compared at commit, the cast is+    /// not (Q5).+    public func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {+        try await repository.shareCharacters(workID: workID)+    }+     // MARK: - LookupCaptureCoordinating (lookup-first re-share, Decision 4)      public func captureLookup(rawURL: String) async throws -> CaptureLookupDisposition {         try await repository.captureLookup(rawURL: rawURL)     } +    /// The sheet this lookup feeds shows the matched work's cast, so it asks for+    /// it here — the one read, inside the lookup the sheet already waits on+    /// (T-1916, Q5).     public func captureLookup(rawURL: String, captureTitle: String?) async throws -> CaptureLookupDisposition {-        try await repository.captureLookup(rawURL: rawURL, captureTitle: captureTitle)+        try await repository.captureLookup(+            rawURL: rawURL, captureTitle: captureTitle, includeCharacters: true)     }      public func commitReShareUpdate(
Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift Modified +84 / −0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swiftindex de28624..05bc998 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CaptureViewModel.swift@@ -1,7 +1,12 @@ import Foundation+import OSLog  // MARK: - Capture state machine for the extension UI +/// `captureLogger` is file-private to `LibraryRepository+Capture.swift`, so the+/// sheet's own failed-read line gets its own logger of the same shape (Q15).+private let captureViewModelLogger = Logger(subsystem: "AsterismCore", category: "CaptureViewModel")+ /// View model state for the capture sheet. /// Carries CaptureOutcome for projection display and preserves CapturePreparation + CaptureDraft. public enum CaptureViewState: Sendable, Equatable {@@ -46,6 +51,35 @@ public final class CaptureViewModel {     /// The generation at which the current projection was produced.     private var projectionGeneration: Int = -1 +    /// The cast of the projected work, for the characters row (T-1916).+    /// Empty until a read returns, and after a read that failed — a work without+    /// characters and a failed read are one state on the sheet.+    public private(set) var characters: [ShareCharacter] = []++    /// The work id `characters` describes, and the one a read is out for.+    /// Both key on the work rather than on `generation`, which advances on every+    /// keystroke and would discard a read the reader started typing during (Q12).+    internal private(set) var charactersWorkID: UUID?+    internal private(set) var inFlightWorkID: UUID?++    /// The cast the sheet may draw now: `characters` only while it describes the+    /// work the sheet projects (Q22). A projection that moves to another work —+    /// or to one that does not exist yet — shows nothing until its own read+    /// lands, rather than the previous work's cast under the new work's title.+    public var displayedCharacters: [ShareCharacter] {+        charactersWorkID == currentOutcome?.projectedWorkID ? characters : []+    }++    /// Whether the projected work still needs a read (Q23). The one predicate:+    /// `loadCharactersIfNeeded` returns early on it, and the bridge consults it+    /// before spawning a task rather than keeping a copy of the rule.+    public var needsCharacterLoad: Bool {+        guard let target = currentOutcome?.projectedWorkID else {+            return charactersWorkID != nil || inFlightWorkID != nil+        }+        return target != charactersWorkID && target != inFlightWorkID+    }+     /// Whether save is currently possible (ready or failed states, with valid title and projection).     public var canSave: Bool {         switch state {@@ -155,6 +189,56 @@ public final class CaptureViewModel {         await reprojectIfNeeded(coordinator: coordinator)     } +    // MARK: - Characters (T-1916)++    /// Read the projected work's cast, at most once per work id.+    ///+    /// Called after every published state change. A projection that names no+    /// existing work — `.create`, `.ambiguous`, a failed projection, a blank+    /// title — clears the row; a projection that names the same work as the last+    /// read, which is every keystroke of a note edit, reads nothing.+    ///+    /// Never throws: a failed read leaves the sheet in the state a work without+    /// characters produces.+    ///+    /// - Returns: Whether the published cast changed, so the caller knows+    ///   whether anything is worth redrawing (Q23). A read the projection+    ///   superseded, and a call with nothing to do, both answer `false`.+    @discardableResult+    public func loadCharactersIfNeeded(coordinator: any CaptureCoordinating) async -> Bool {+        guard needsCharacterLoad else { return false }+        guard let target = currentOutcome?.projectedWorkID else {+            let wasPublished = charactersWorkID != nil || !characters.isEmpty+            characters = []+            charactersWorkID = nil+            inFlightWorkID = nil+            return wasPublished+        }++        inFlightWorkID = target+        let fetched: [ShareCharacter]+        do {+            fetched = try await coordinator.shareCharacters(workID: target)+        } catch {+            // The row is the reader's convenience, not their capture: the cause+            // reaches diagnostics, the sheet draws a characterless work (Q15).+            captureViewModelLogger.debug(+                "Capture characters: read failed — \(String(describing: error), privacy: .public)")+            fetched = []+        }+        // Release the marker whenever it is still this read's, so a read+        // superseded before it returned does not block a later one for the same+        // work. A newer read has already overwritten it with its own id.+        if inFlightWorkID == target { inFlightWorkID = nil }++        // Publish only while the sheet still projects the work that was read.+        guard currentOutcome?.projectedWorkID == target else { return false }+        let changed = charactersWorkID != target || characters != fetched+        charactersWorkID = target+        characters = fetched+        return changed+    }+     // MARK: - Save      /// Save the current draft. Uses the currently displayed contract.
Asterism/AsterismShareExtension/ShareCharactersRow.swift Added +29 / −0
diff --git a/Asterism/AsterismShareExtension/ShareCharactersRow.swift b/Asterism/AsterismShareExtension/ShareCharactersRow.swiftnew file mode 100644index 0000000..5137b02--- /dev/null+++ b/Asterism/AsterismShareExtension/ShareCharactersRow.swift@@ -0,0 +1,29 @@+import SwiftUI++/// `Characters: Alice (Al, Ally), Bob` — the one cast row both capture sheets+/// draw (T-1916, Q6), in the metadata card's row shape with the glyph on the+/// first line of a text that may wrap to several (Q20).+///+/// The text is `ShareCharacterRow.text(for:)`'s, which is nil for an empty+/// list — a work without characters, a projection that names no existing work+/// and a failed read all draw nothing at all, so the caller decides whether the+/// row exists and this view only lays it out.+struct ShareCharactersRow: View {+    let text: String+    let accessibilityIdentifier: String++    var body: some View {+        HStack(alignment: .firstTextBaseline, spacing: 6) {+            Image(systemName: "person.2")+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityHidden(true)+            Text(text)+                .font(.subheadline)+                .fixedSize(horizontal: false, vertical: true)+        }+        .accessibilityElement(children: .combine)+        .accessibilityLabel(text)+        .accessibilityIdentifier(accessibilityIdentifier)+    }+}
Asterism/AsterismShareExtension/CaptureView.swift Modified +37 / −0
diff --git a/Asterism/AsterismShareExtension/CaptureView.swift b/Asterism/AsterismShareExtension/CaptureView.swiftindex d2d159b..92f6c16 100644--- a/Asterism/AsterismShareExtension/CaptureView.swift+++ b/Asterism/AsterismShareExtension/CaptureView.swift@@ -300,6 +300,16 @@ struct CaptureView: View {                 .accessibilityIdentifier("capture.noChapter")             } +            // The projected work's cast (T-1916, Q6). One wrapping text row,+            // filled in a refresh after the sheet appears; nothing renders for+            // a work without characters, for a projection that names no+            // existing work, or for a failed read — the row text is nil for an+            // empty list in all three cases.+            if let charactersText = ShareCharacterRow.text(for: viewModel.characters) {+                ShareCharactersRow(+                    text: charactersText, accessibilityIdentifier: "capture.characters")+            }+             // Actionable state indicator             if outcome.actionable {                 HStack(spacing: 6) {@@ -473,6 +483,8 @@ final class ObservableCaptureViewModel: ObservableObject {     private var suppressDidSet = false      @Published var state: CaptureViewState+    /// The projected work's cast, filled in after the sheet renders (T-1916).+    @Published var characters: [ShareCharacter] = []     @Published var manualTitle: String = "" {         didSet {             guard !suppressDidSet else { return }@@ -550,10 +562,35 @@ final class ObservableCaptureViewModel: ObservableObject {     func refreshState() {         suppressDidSet = true         state = viewModel.state+        characters = viewModel.displayedCharacters         // Sync binding values from the view model's current draft without triggering didSet loops         if let draft = viewModel.currentDraft {             if note != draft.note { note = draft.note }         }         suppressDidSet = false+        scheduleCharacterLoad()+    }++    /// Fills the characters row in from the projected work id (T-1916, Q13).+    ///+    /// Every awaited view-model call refreshes through `refreshState()` — the+    /// initial load from `ShareCaptureRootView`, each setter, the post-save+    /// re-projection — so triggering the read here needs no caller list. The+    /// read is spawned, never awaited: the first render must not wait on it.+    ///+    /// The "needs a read" rule lives in `CaptureViewModel.needsCharacterLoad`,+    /// which `loadCharactersIfNeeded` itself returns early on (Q23) — the bridge+    /// keeps no copy of it. It refreshes only when the read published something,+    /// so a no-op read cannot start another refresh and the spin Q21 guarded+    /// against is gone even without the check; the check remains so a refresh+    /// per keystroke does not spawn a task per keystroke to find that out.+    private func scheduleCharacterLoad() {+        guard let coordinator, viewModel.needsCharacterLoad else { return }+        Task { [weak self] in+            guard let self else { return }+            if await self.viewModel.loadCharactersIfNeeded(coordinator: coordinator) {+                self.refreshState()+            }+        }     } }
Asterism/AsterismShareExtension/ReShareCaptureView.swift Modified +9 / −0
diff --git a/Asterism/AsterismShareExtension/ReShareCaptureView.swift b/Asterism/AsterismShareExtension/ReShareCaptureView.swiftindex 8b85a72..585e65f 100644--- a/Asterism/AsterismShareExtension/ReShareCaptureView.swift+++ b/Asterism/AsterismShareExtension/ReShareCaptureView.swift@@ -127,6 +127,15 @@ struct ReShareCaptureView: View {         // Banner: "Noted <date> — editing existing entry"         editBanner(firstCapturedAt: state.firstCapturedAt) +        // The work's cast (T-1916, Q6). One wrapping text row; nothing renders+        // for a work without characters, which is also what a failed read+        // leaves behind — the row text is nil for an empty list.+        if let charactersText = ShareCharacterRow.text(for: state.characters) {+            ShareCharactersRow(+                text: charactersText, accessibilityIdentifier: "reshare.characters")+                .frame(maxWidth: .infinity, alignment: .leading)+        }+         // Error banner (after save failure)         if let errorMessage = state.errorMessage {             errorBanner(message: errorMessage)
Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swift Added +288 / −0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swiftnew file mode 100644index 0000000..1c1e4b0--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareCharacterProjectionTests.swift@@ -0,0 +1,288 @@+import Foundation+import Testing++@testable import AsterismCore++// T-1916: the cast a capture sheet shows for a work it already knows.+//+// Two claims. The row text is a pure function of the projection, so the+// extension decides nothing about it (Q6); and the projection answers with the+// work page's characters, in the work page's order, for the same store.++@Suite("Share character row text")+struct ShareCharacterRowTests {++    @Test("No characters means no row at all")+    func emptyListHasNoText() {+        #expect(ShareCharacterRow.text(for: []) == nil)+    }++    @Test("Names are comma-separated, aliases parenthesised in the order given")+    func namesAndAliasesRead() {+        let text = ShareCharacterRow.text(for: [+            ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),+            ShareCharacter(name: "Bob"),+        ])+        #expect(text == "Characters: Alice (Al, Ally), Bob")+    }++    @Test("An alias list is drawn as stored — sorted, not re-ordered here")+    func aliasOrderIsTheStoredOrder() {+        // `CharacterAuthoredContent` sorts aliases at construction, so the row+        // has nothing left to decide; it must not impose a second order.+        let content = CharacterAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])+        let text = ShareCharacterRow.text(for: [+            ShareCharacter(name: content.name, aliases: content.aliases)+        ])+        #expect(content.aliases == ["Ann", "Zephyr"])+        #expect(text == "Characters: Hanna (Ann, Zephyr)")+    }++    @Test("A name containing a comma is joined verbatim (accepted, Q6)")+    func commaInANameIsNotEscaped() {+        let text = ShareCharacterRow.text(for: [+            ShareCharacter(name: "Vance, the Elder"),+            ShareCharacter(name: "Bo"),+        ])+        #expect(text == "Characters: Vance, the Elder, Bo")+    }+}++@Suite("Share characters read", .serialized)+struct ShareCharacterReadTests {++    private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000001")!+    private static let otherWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000002")!+    private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-00000000000A")!+    private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-00000000000B")!+    private static let zedFirst = UUID(uuidString: "1F000000-0000-4000-8000-000000000C01")!+    private static let zedSecond = UUID(uuidString: "1F000000-0000-4000-8000-000000000C02")!+    private static let orphan = UUID(uuidString: "1F000000-0000-4000-8000-00000000000F")!++    private func seeded(+        works: [M5SeedWork]? = nil, characters: [M5SeedCharacter]+    ) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: works ?? [+                M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example")+            ],+            characters: characters)+        return fixture+    }++    @Test("The work's characters come back in normalised name order, UUID as the tie-break")+    func charactersAreOrderedLikeTheWorkPage() async throws {+        let fixture = try await seeded(characters: [+            // Two names whose raw order and normalised order disagree, so the+            // assertion cannot pass on a plain string sort.+            M5SeedCharacter(id: Self.bob, name: "Bob", workID: Self.workID),+            M5SeedCharacter(+                id: Self.alice, name: "alice", aliases: ["Ally", "Al"], workID: Self.workID),+            // Same normalised name, distinct UUIDs: the tie-break decides.+            M5SeedCharacter(id: Self.zedSecond, name: "Zed", workID: Self.workID),+            M5SeedCharacter(id: Self.zedFirst, name: "Zed", workID: Self.workID),+        ])++        let characters = try await fixture.repository.shareCharacters(workID: Self.workID)+        #expect(characters == [+            ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),+            ShareCharacter(name: "Bob"),+            ShareCharacter(name: "Zed"),+            ShareCharacter(name: "Zed"),+        ])+        #expect(+            ShareCharacterRow.text(for: characters)+                == "Characters: alice (Al, Ally), Bob, Zed, Zed")++        // The claim that matters: one order, shared with the page (Q9).+        let detail = try await fixture.repository.workDetail(id: Self.workID)+        #expect(detail.characters.map(\.name) == characters.map(\.name))+        #expect(detail.characters.map(\.aliases) == characters.map(\.aliases))+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn character shows the content the work page presents, with no marker")+    func tornCharacterUsesPresentedContent() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: Self.alice, name: "Alice", aliases: ["Al"], note: "brave",+                workID: Self.workID),+            M5SeedCharacter(+                id: Self.alice, name: "Alice Vance", aliases: ["Al"], note: "wary",+                workID: Self.workID),+        ])++        let characters = try await fixture.repository.shareCharacters(workID: Self.workID)+        let detail = try await fixture.repository.workDetail(id: Self.workID)+        let presented = try #require(detail.characters.first)+        #expect(presented.isTorn)+        // One entry for one character, whatever its rows disagree about, and+        // the same text the page shows for it.+        #expect(characters == [ShareCharacter(name: presented.name, aliases: presented.aliases)])+        withExtendedLifetime(fixture) {}+    }++    @Test("An orphaned character is invisible, as it is on the work page")+    func orphanIsAbsent() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),+            M5SeedCharacter(id: Self.orphan, name: "Nobody", workID: nil),+        ])++        let characters = try await fixture.repository.shareCharacters(workID: Self.workID)+        #expect(characters == [ShareCharacter(name: "Alice")])+        withExtendedLifetime(fixture) {}+    }++    @Test("Duplicate Work rows sharing an id union their characters, each listed once")+    func duplicateWorkRowsUnionWithoutDoubles() async throws {+        let fixture = try await seeded(+            works: [+                M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+                M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+            ],+            characters: [+                // One character per row of the same work group…+                M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID),+                M5SeedCharacter(+                    id: Self.bob, name: "Bob", workID: Self.workID, workRowIndex: 1),+                // …and one character that is itself two rows: still one entry.+                M5SeedCharacter(+                    id: Self.zedFirst, name: "Zed", note: "one", workID: Self.workID),+                M5SeedCharacter(+                    id: Self.zedFirst, name: "Zed", note: "two", workID: Self.workID,+                    workRowIndex: 1),+            ])++        let characters = try await fixture.repository.shareCharacters(workID: Self.workID)+        #expect(characters.map(\.name) == ["Alice", "Bob", "Zed"])+        withExtendedLifetime(fixture) {}+    }++    @Test("A work without characters and an unknown id both answer nothing, neither throws")+    func emptyAnswersDoNotThrow() async throws {+        let fixture = try await seeded(+            works: [+                M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+                M5SeedWork(id: Self.otherWorkID, displayTitle: "Another", hostname: "c.example"),+            ],+            characters: [+                M5SeedCharacter(id: Self.alice, name: "Alice", workID: Self.workID)+            ])++        let characterless = try await fixture.repository.shareCharacters(workID: Self.otherWorkID)+        #expect(characterless == [])+        // An id the sheet projected but the store does not hold: an empty cast,+        // not a `recordNotFound`.+        let unknown = try await fixture.repository.shareCharacters(workID: UUID())+        #expect(unknown == [])+        withExtendedLifetime(fixture) {}+    }+}++// T-1916 task 3: the re-share sheet's cast rides along in the lookup it already+// waits on (Q5), and only for the caller that displays it (Q10).++@Suite("Share characters on the capture lookup", .serialized)+struct ShareCharacterLookupTests {++    private static let workID = UUID(uuidString: "1F000000-0000-4000-8000-000000000101")!+    private static let castlessWorkID = UUID(uuidString: "1F000000-0000-4000-8000-000000000102")!+    private static let entryID = UUID(uuidString: "1F000000-0000-4000-8000-0000000001E1")!+    private static let alice = UUID(uuidString: "1F000000-0000-4000-8000-0000000001AA")!+    private static let bob = UUID(uuidString: "1F000000-0000-4000-8000-0000000001BB")!++    private static let rawURL = "https://c.example/chapter-1"++    /// One entry on `rawURL`, attached to `workID` unless told otherwise, so the+    /// lookup answers `.edit` for a work the store already holds.+    private func seeded(+        workID: UUID? = ShareCharacterLookupTests.workID, characters: [M5SeedCharacter]+    ) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(id: Self.workID, displayTitle: "A Serial", hostname: "c.example"),+                M5SeedWork(+                    id: Self.castlessWorkID, displayTitle: "Another Serial",+                    hostname: "c.example"),+            ],+            entries: [+                M5SeedEntry(+                    id: Self.entryID, captureTitle: "Chapter 1", hostname: "c.example",+                    path: "chapter-1", chapterTitle: "Chapter 1", workID: workID)+            ],+            characters: characters)+        return fixture+    }++    private func editBasis(+        _ fixture: M5Fixture, includeCharacters: Bool+    ) async throws -> ReShareEditBasis {+        let disposition = try await fixture.repository.captureLookup(+            rawURL: Self.rawURL, captureTitle: "Chapter 1",+            includeCharacters: includeCharacters)+        guard case .edit(let basis) = disposition else {+            Issue.record("Expected an edit disposition, got \(disposition)")+            throw LibraryRepositoryError.recordNotFound(type: "Entry", id: Self.entryID)+        }+        return basis+    }++    private static let cast: [M5SeedCharacter] = [+        M5SeedCharacter(id: bob, name: "Bob", workID: workID),+        M5SeedCharacter(id: alice, name: "alice", aliases: ["Ally", "Al"], workID: workID),+    ]++    @Test("An opted-in lookup carries the matched work's cast in the work page's order")+    func lookupCarriesCharactersWhenAsked() async throws {+        let fixture = try await seeded(characters: Self.cast)++        let basis = try await editBasis(fixture, includeCharacters: true)+        #expect(basis.entryID == Self.entryID)+        #expect(basis.characters == [+            ShareCharacter(name: "alice", aliases: ["Al", "Ally"]),+            ShareCharacter(name: "Bob"),+        ])+        // The same list the standalone read gives for that work — one order,+        // one projection.+        let direct = try await fixture.repository.shareCharacters(workID: Self.workID)+        #expect(basis.characters == direct)+        withExtendedLifetime(fixture) {}+    }++    @Test("A lookup that did not ask carries no cast, whatever the work holds")+    func lookupWithoutTheFlagCarriesNothing() async throws {+        let fixture = try await seeded(characters: Self.cast)++        #expect(try await editBasis(fixture, includeCharacters: false).characters == [])+        // The default the drain and every other caller take.+        let defaulted = try await fixture.repository.captureLookup(+            rawURL: Self.rawURL, captureTitle: "Chapter 1")+        guard case .edit(let basis) = defaulted else {+            Issue.record("Expected an edit disposition, got \(defaulted)")+            return+        }+        #expect(basis.characters == [])+        withExtendedLifetime(fixture) {}+    }++    @Test("A matched work without characters carries an empty cast, as does an unattached entry")+    func characterlessAndUnattachedBothCarryNothing() async throws {+        let castless = try await seeded(+            workID: Self.castlessWorkID,+            characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])+        #expect(try await editBasis(castless, includeCharacters: true).characters == [])+        withExtendedLifetime(castless) {}++        // No work at all: the same empty row, no failure.+        let unattached = try await seeded(+            workID: nil,+            characters: [M5SeedCharacter(id: Self.alice, name: "alice", workID: Self.workID)])+        #expect(try await editBasis(unattached, includeCharacters: true).characters == [])+        withExtendedLifetime(unattached) {}+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift Modified +87 / −2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swiftindex 612a35b..92cfc40 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ReShareExtensionUITests.swift@@ -252,6 +252,90 @@ struct ReShareExtensionUITests {         let label = ReShareActionLabels.primaryActionAccessibilityLabel(for: fixture.viewModel.lookupState)         #expect(label == "Update existing entry")     }++    // MARK: - The work's cast on the edit sheet (T-1916)++    private static let cast = [+        ShareCharacter(name: "Alice", aliases: ["Al", "Ally"]),+        ShareCharacter(name: "Bob"),+    ]++    @Test("Edit state carries the basis cast after lookup")+    @MainActor func editStateCarriesCharacters() async throws {+        let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+        await fixture.viewModel.loadWithLookup(+            payload: fixture.payload,+            coordinator: fixture.coordinator+        )+        guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+            Issue.record("Expected readyEdit")+            return+        }+        #expect(state.characters == Self.cast)+        #expect(ShareCharacterRow.text(for: state.characters)+            == "Characters: Alice (Al, Ally), Bob")+    }++    @Test("A lookup with no cast leaves the edit state's list empty")+    @MainActor func editStateWithoutCharactersIsEmpty() async throws {+        let fixture = ReShareUIFixture.editExisting()+        await fixture.viewModel.loadWithLookup(+            payload: fixture.payload,+            coordinator: fixture.coordinator+        )+        guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+            Issue.record("Expected readyEdit")+            return+        }+        #expect(state.characters.isEmpty)+        #expect(ShareCharacterRow.text(for: state.characters) == nil)+    }++    /// Q11: the commit path walks no characters, so its refreshed basis carries+    /// none. The row must not empty out under a reader who tapped Update twice.+    @Test("A stale refresh keeps the cast already shown")+    @MainActor func staleRefreshKeepsCharacters() async throws {+        let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+        await fixture.viewModel.loadWithLookup(+            payload: fixture.payload,+            coordinator: fixture.coordinator+        )+        fixture.coordinator.reShareOutcome = .stale(ReShareEditBasis(+            entryID: fixture.entryID,+            hostname: "example.com",+            identityKey: "https://example.com/chapter-1",+            title: "Chapter 1",+            persistedNote: "concurrent note",+            persistedRating: nil,+            persistedModifiedAt: Date(timeIntervalSince1970: 1_721_500_000),+            firstCapturedAt: Date(timeIntervalSince1970: 1_720_000_000)+        ))+        await fixture.viewModel.submitUpdate(coordinator: fixture.coordinator)++        guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+            Issue.record("Expected readyEdit after stale")+            return+        }+        #expect(state.characters == Self.cast)+        #expect(state.persistedNote == "concurrent note")+    }++    @Test("A failed save keeps the cast on the rebuilt edit state")+    @MainActor func saveFailureKeepsCharacters() async throws {+        let fixture = ReShareUIFixture.editExisting(characters: Self.cast)+        await fixture.viewModel.loadWithLookup(+            payload: fixture.payload,+            coordinator: fixture.coordinator+        )+        fixture.coordinator.reShareShouldThrow = true+        await fixture.viewModel.submitUpdate(coordinator: fixture.coordinator)++        guard case .readyEdit(let state) = fixture.viewModel.lookupState else {+            Issue.record("Expected readyEdit with error")+            return+        }+        #expect(state.characters == Self.cast)+    } }  // MARK: - Test Fixture@@ -270,7 +354,7 @@ private struct ReShareUIFixture {         self.entryID = entryID     } -    static func editExisting() -> ReShareUIFixture {+    static func editExisting(characters: [ShareCharacter] = []) -> ReShareUIFixture {         let entryID = UUID()         let coordinator = FakeReShareUICoordinator()         coordinator.lookupResult = .edit(ReShareEditBasis(@@ -281,7 +365,8 @@ private struct ReShareUIFixture {             persistedNote: "existing note",             persistedRating: .up,             persistedModifiedAt: Date(timeIntervalSince1970: 1_721_000_000),-            firstCapturedAt: Date(timeIntervalSince1970: 1_720_000_000)+            firstCapturedAt: Date(timeIntervalSince1970: 1_720_000_000),+            characters: characters         ))         let payload = SharePayload(             providerURL: "https://example.com/chapter-1",
Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift Modified +275 / −3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swiftindex eae4a67..61e9c1f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CaptureStateTests.swift@@ -405,6 +405,253 @@ struct CaptureStateTests {         #expect(outcome.hasParsedTitle == cell.hidesRawTitle,                 "\(cell.name): raw page title \"\(cell.rawPageTitle)\"")     }++    // MARK: - Characters row (T-1916)++    @Test("A reuse projection reads the projected work's cast once")+    @MainActor func reuseProjectionReadsCharacters() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([+            ShareCharacter(name: "Alice", aliases: ["Al"]),+            ShareCharacter(name: "Bob")+        ])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        #expect(fixture.coordinator.shareCharactersCalls == [fixture.coordinator.projectedWorkID])+        #expect(fixture.viewModel.characters.map(\.name) == ["Alice", "Bob"])+        #expect(fixture.viewModel.charactersWorkID == fixture.coordinator.projectedWorkID)+        #expect(fixture.viewModel.inFlightWorkID == nil)+    }++    @Test("A claim projection reads the cast too — it names an existing work (Q3)")+    @MainActor func claimProjectionReadsCharacters() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        let claimedID = UUID()+        // A claim carries the work id with no `workOutcome`, exactly as+        // `computeCaptureOutcome` produces it.+        fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome(+            projectedChapter: "5", projectedWorkTitle: "Test Work",+            workOutcome: nil, projectedWorkID: claimedID,+            actionable: false, intentionallyUnattached: false,+            composedAssignment: .claim(workID: claimedID)+        )))+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        #expect(fixture.coordinator.shareCharactersCalls == [claimedID])+        #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+    }++    @Test("A projection that names no existing work reads nothing",+          arguments: NoCastCase.all)+    @MainActor fileprivate func noWorkProjectionReadsNothing(cell: NoCastCase) async throws {+        let fixture = CaptureStateFixture.taughtSite()+        if let outcome = cell.outcome {+            fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: outcome))+        } else {+            fixture.coordinator.projectCaptureResult = .failure(+                CaptureViewModelError.projectionFailed("no projection"))+        }+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        #expect(fixture.coordinator.shareCharactersCalls.isEmpty, "\(cell.name)")+        #expect(fixture.viewModel.characters.isEmpty, "\(cell.name)")+        #expect(fixture.viewModel.charactersWorkID == nil, "\(cell.name)")+    }++    @Test("A note edit that leaves the work unchanged reads nothing further")+    @MainActor func unchangedWorkDoesNotRefetch() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+        #expect(fixture.coordinator.shareCharactersCalls.count == 1)++        await fixture.viewModel.setNote("A note", coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+        await fixture.viewModel.setNote("A longer note", coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        #expect(fixture.coordinator.shareCharactersCalls.count == 1)+        #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+    }++    @Test("A read superseded before it returns is never published")+    @MainActor func supersededReadIsNotPublished() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        let firstWorkID = fixture.coordinator.projectedWorkID+        let secondWorkID = UUID()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)++        // Reproject onto another work while the read is suspended: the view model+        // is awaiting the coordinator, so the main actor is free for the edit.+        let coordinator = fixture.coordinator+        let viewModel = fixture.viewModel+        coordinator.shareCharactersHook = { @Sendable in+            coordinator.projectedWorkID = secondWorkID+            await viewModel.setNote("Edited mid-read", coordinator: coordinator)+        }+        await viewModel.loadCharactersIfNeeded(coordinator: coordinator)++        #expect(coordinator.shareCharactersCalls == [firstWorkID])+        #expect(viewModel.currentOutcome?.projectedWorkID == secondWorkID)+        #expect(viewModel.characters.isEmpty)+        #expect(viewModel.charactersWorkID == nil)+        // The marker is released, so the second work can still be read for.+        #expect(viewModel.inFlightWorkID == nil)+        coordinator.shareCharactersHook = nil+        await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+        #expect(coordinator.shareCharactersCalls == [firstWorkID, secondWorkID])+        #expect(viewModel.charactersWorkID == secondWorkID)+    }++    @Test("A failed read leaves the state a castless work produces")+    @MainActor func failedReadMatchesEmptyRead() async throws {+        let failing = CaptureStateFixture.taughtSite()+        failing.coordinator.shareCharactersResult = .failure(+            CaptureViewModelError.invalidState("Simulated read failure"))+        await failing.viewModel.load(payload: failing.defaultPayload, coordinator: failing.coordinator)+        await failing.viewModel.loadCharactersIfNeeded(coordinator: failing.coordinator)++        let castless = CaptureStateFixture.taughtSite()+        castless.coordinator.shareCharactersResult = .success([])+        await castless.viewModel.load(payload: castless.defaultPayload, coordinator: castless.coordinator)+        await castless.viewModel.loadCharactersIfNeeded(coordinator: castless.coordinator)++        #expect(failing.viewModel.characters == castless.viewModel.characters)+        #expect(failing.viewModel.charactersWorkID == castless.viewModel.charactersWorkID)+        #expect(failing.viewModel.inFlightWorkID == castless.viewModel.inFlightWorkID)+        #expect(ShareCharacterRow.text(for: failing.viewModel.characters) == nil)+    }++    @Test("Losing the projected work clears the cast")+    @MainActor func losingTheWorkClearsCharacters() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+        #expect(fixture.viewModel.characters.isEmpty == false)++        // The reader retitles onto a work that does not exist yet.+        fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome(+            projectedChapter: "5", projectedWorkTitle: "Brand New Work",+            workOutcome: .create(parsedWorkTitle: "Brand New Work"), projectedWorkID: nil,+            actionable: false, intentionallyUnattached: false+        )))+        await fixture.viewModel.setNote("Retitled", coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        #expect(fixture.viewModel.characters.isEmpty)+        #expect(fixture.viewModel.charactersWorkID == nil)+        #expect(fixture.coordinator.shareCharactersCalls.count == 1)+    }++    @Test("A projection that moves to another work draws nothing until its own read lands")+    @MainActor func displayedCharactersDropOnAWorkChange() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+        #expect(fixture.viewModel.displayedCharacters == [ShareCharacter(name: "Alice")])++        // The projection moves to another existing work. Nothing has been read+        // for it yet, so the sheet must not show the first work's cast under the+        // second work's title (Q22).+        fixture.coordinator.projectedWorkID = UUID()+        await fixture.viewModel.setNote("Retitled onto another work", coordinator: fixture.coordinator)++        #expect(fixture.viewModel.displayedCharacters.isEmpty)+        // The loaded list is untouched — the rule is about what may be drawn.+        #expect(fixture.viewModel.characters == [ShareCharacter(name: "Alice")])+    }++    @Test("A projection that names no existing work draws nothing at once")+    @MainActor func displayedCharactersDropOnACreate() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)++        fixture.coordinator.projectCaptureResult = .success(makeContract(outcome: CaptureOutcome(+            projectedChapter: "5", projectedWorkTitle: "Brand New Work",+            workOutcome: .create(parsedWorkTitle: "Brand New Work"), projectedWorkID: nil,+            actionable: false, intentionallyUnattached: false+        )))+        await fixture.viewModel.setNote("Retitled", coordinator: fixture.coordinator)++        // Before the clearing read runs, and after it.+        #expect(fixture.viewModel.displayedCharacters.isEmpty)+        await fixture.viewModel.loadCharactersIfNeeded(coordinator: fixture.coordinator)+        #expect(fixture.viewModel.displayedCharacters.isEmpty)+    }++    @Test("A read reports whether it published, so the bridge refreshes only then")+    @MainActor func loadReportsWhetherItPublished() async throws {+        let fixture = CaptureStateFixture.taughtSite()+        fixture.coordinator.shareCharactersResult = .success([ShareCharacter(name: "Alice")])+        await fixture.viewModel.load(payload: fixture.defaultPayload, coordinator: fixture.coordinator)++        let published = await fixture.viewModel.loadCharactersIfNeeded(+            coordinator: fixture.coordinator)+        #expect(published)+        // Nothing left to read for the same work.+        let again = await fixture.viewModel.loadCharactersIfNeeded(+            coordinator: fixture.coordinator)+        #expect(again == false)++        // A read the projection supersedes publishes nothing, so it reports+        // nothing (Q23).+        let coordinator = fixture.coordinator+        let viewModel = fixture.viewModel+        coordinator.projectedWorkID = UUID()+        await viewModel.setNote("Onto another work", coordinator: coordinator)+        coordinator.shareCharactersHook = { @Sendable in+            coordinator.projectedWorkID = UUID()+            await viewModel.setNote("Edited mid-read", coordinator: coordinator)+        }+        let superseded = await viewModel.loadCharactersIfNeeded(coordinator: coordinator)+        #expect(superseded == false)+    }+}++/// A contract carrying the outcome under test; only the outcome matters to the+/// characters row.+private func makeContract(outcome: CaptureOutcome) -> CaptureContract {+    CaptureContract(+        basis: CaptureBasis(siteMode: .taught, hostname: "taught.example", activePattern: nil, works: []),+        request: CaptureRequest(+            captureTitle: "Chapter 5", captureTitleSource: .safariDocument,+            rawURLString: "https://taught.example/ch5", canonicalURLString: nil,+            note: "", rating: nil+        ),+        outcome: outcome)+}++/// One projection that names no existing work, so the sheet shows no cast.+/// A nil `outcome` means the projection itself failed.+private struct NoCastCase: Sendable {+    let name: String+    let outcome: CaptureOutcome?++    static let all: [NoCastCase] = [+        NoCastCase(name: "create — the work does not exist yet", outcome: CaptureOutcome(+            projectedChapter: "5", projectedWorkTitle: "Brand New Work",+            workOutcome: .create(parsedWorkTitle: "Brand New Work"), projectedWorkID: nil,+            actionable: false, intentionallyUnattached: false)),+        NoCastCase(name: "ambiguous — more than one work matched", outcome: CaptureOutcome(+            projectedChapter: "5", projectedWorkTitle: "Test Work",+            workOutcome: .ambiguous(candidates: [+                WorkMatchCandidate(id: UUID(), lastParsedTitle: "Test Work", displayTitle: "Test Work"),+                WorkMatchCandidate(id: UUID(), lastParsedTitle: "Test Work", displayTitle: "Test Work")+            ]),+            projectedWorkID: nil,+            actionable: true, intentionallyUnattached: false)),+        NoCastCase(name: "no outcome — the projection failed", outcome: nil)+    ] }  /// One row of the `hasParsedTitle` truth table. `rawPageTitle` is the title the@@ -492,13 +739,31 @@ private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sen     /// Track legacy save calls — should always be 0 in new flow     var legacySaveCallCount = 0 +    /// The work the default projection resolves to. Stable across projections so+    /// a note edit reprojects to the same work, and settable so a test can move+    /// the sheet to another one. Locked on both sides: the read hook that moves+    /// it runs while `defaultProjectionOutcome` may be reading it.+    private var _projectedWorkID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!+    var projectedWorkID: UUID {+        get { lock.withLock { _projectedWorkID } }+        set { lock.withLock { _projectedWorkID = newValue } }+    }++    private var _shareCharactersCalls: [UUID] = []+    var shareCharactersCalls: [UUID] { lock.withLock { _shareCharactersCalls } }+    var shareCharactersResult: Result<[ShareCharacter], Error> = .success([])+    /// Run while a character read is in flight — the hook the superseded case+    /// needs, since the read is suspended here and the main actor is free.+    var shareCharactersHook: (@Sendable () async -> Void)?+     /// Default projection outcome     private var defaultProjectionOutcome: CaptureOutcome {-        CaptureOutcome(+        let workID = projectedWorkID+        return CaptureOutcome(             projectedChapter: "5",             projectedWorkTitle: "Test Work",-            workOutcome: .reuse(workID: UUID()),-            projectedWorkID: UUID(),+            workOutcome: .reuse(workID: workID),+            projectedWorkID: workID,             actionable: false,             intentionallyUnattached: false         )@@ -543,6 +808,13 @@ private final class FakeCaptureCoordinating: CaptureCoordinating, @unchecked Sen         return CaptureContract(basis: basis, request: request, outcome: defaultProjectionOutcome)     } +    func shareCharacters(workID: UUID) async throws -> [ShareCharacter] {+        lock.withLock { _shareCharactersCalls.append(workID) }+        let hook: (@Sendable () async -> Void)? = lock.withLock { shareCharactersHook }+        if let hook { await hook() }+        return try lock.withLock { shareCharactersResult }.get()+    }+     func commitCapture(_ contract: CaptureContract) async throws -> CaptureCommitOutcome {         lock.withLock { _commitCaptureCallCount += 1 }         let shouldThrow: Bool = lock.withLock { commitShouldThrow }
docs/asterism-design.md Modified +2 / −2
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex 405b710..85cad71 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -186,7 +186,7 @@ Whatever wins becomes the immutable captureTitle, with its source recorded. Shar One layout across all states. Keyboard up and note field focused on appear, always.  Top to bottom:-1. **Metadata strip** (read-only confirmation): site glyph, work name, chapter title, site name. Never edited per-capture — wrong parses are a teach-mode problem, fixed once per site.+1. **Metadata strip** (read-only confirmation): site glyph, work name, chapter title, site name, and — where the capture projects onto a work the library already holds — that work's cast as one wrapping `Characters: Alice (Al, Ally), Bob` row, names and aliases only. Never edited per-capture — wrong parses are a teach-mode problem, fixed once per site. 2. **Note field**, multiline, focused. 3. **Rating**: two toggle buttons (▲ ▼) beside Save. Tap to set, tap again to clear. Absent by default; no rating tap ever required. 4. **Save** button.@@ -198,7 +198,7 @@ Nothing else — curation lives in the app. | State | Trigger | Differences | |---|---|---| | Parsed | Known site, parse succeeds | Baseline as above. |-| Editing | Identity key matches existing entry | "Noted <date> — editing existing entry" banner; note pre-filled, cursor at end; button reads Update. |+| Editing | Identity key matches existing entry | "Noted <date> — editing existing entry" banner, the entry's work's cast in the `Characters:` row directly beneath it; note pre-filled, cursor at end; button reads Update. | | New site | Unknown hostname | Capture title shown raw; "New site — title saved, teach later in app" banner; entry saved flagged unparsed. **No teach mode at capture time.** | | No title | Fetch fallback failed | Title editable inline. | 
CHANGELOG.md Modified +80 / −0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex b7a660f..27a9481 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -81,6 +81,86 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **Share sheet characters verified (share-sheet-characters, T-1916, phase 5:+  Verification).** The full pre-commit bar passes with the feature in place:+  `make test-core` (1,930 tests, `verify-identity` confirming the extension+  still links AsterismCore only) and `make test-quick` (840 tests) both green,+  no new compiler warnings in any touched file (checked with forced recompiles+  against the known pre-existing warnings as controls), and+  `PendingCaptureDrain` still calls the two-parameter lookup, so the drain never+  reads characters (Q10). No escalation trigger fired; the implementation-time+  departures are Q16–Q21. The spec is done.++- **The capture sheets draw the work's cast (share-sheet-characters, T-1916,+  phase 4: Extension views).** Both extension sheets render one wrapping+  `Characters: Alice (Al, Ally), Bob` row from `ShareCharacterRow.text(for:)`+  in the metadata card's row shape with a `person.2` glyph (Q6, Q20): the+  re-share sheet directly after the "Noted …" banner (`reshare.characters`),+  the new-capture sheet after the chapter row and before the actionable flag+  (`capture.characters`), each with the row text as its accessibility label and+  nothing at all for an empty list. `ObservableCaptureViewModel` publishes+  `characters` and its `refreshState()` — the choke point every awaited call+  passes through (Q13) — spawns `loadCharactersIfNeeded` and a follow-up refresh+  whenever the projected work id differs from both the loaded and the in-flight+  id (Q21), so the first render never waits on the read and the bridge cannot+  spin. `Asterism Development` builds with no new warnings; `make test-quick`+  green. No UI tests, by decision. A pre-push review then moved the publish rule+  into `CaptureViewModel.displayedCharacters` so a work change never shows the+  previous work's cast (Q22), made `loadCharactersIfNeeded` report whether it+  published so the bridge owns no copy of the guard (Q23), folded both rows into+  `ShareCharactersRow`, and computed `entryGroups` once per lookup (Q24).++- **The new-capture sheet's characters, keyed by projected work+  (share-sheet-characters, T-1916, phase 3: New-capture arm).**+  `CaptureCoordinating.shareCharacters(workID:)` is a protocol requirement with+  an extension default of `[]`, and `CaptureCoordinator` forwards it to the+  repository's shared-lock read (Q5). `CaptureViewModel` gains `characters`,+  `charactersWorkID`, `inFlightWorkID` and `loadCharactersIfNeeded(coordinator:)`:+  a projection that names no existing work (`.create`, `.ambiguous`, a failed+  projection) clears the row (Q18); one naming the same work as the last read —+  every keystroke of a note edit — reads nothing; a read whose work is+  superseded before it returns is never published, and the in-flight marker is+  released by the read that owns it so a later read for the same work is not+  blocked (Q19). Guarded by work id, never by `generation` (Q12). A failed read+  leaves the state a characterless work produces. Seven `CaptureStateTests`+  cases pin the behaviour through `FakeCaptureCoordinating`. The bridge and the+  views are phase 4.++- **The re-share sheet's cast, from the lookup to the edit state+  (share-sheet-characters, T-1916, phase 2: Re-share arm).** `ReShareEditBasis`+  carries `characters` (display-only, like `title` — the staleness comparison+  ignores it, Q8) with a `with(characters:)` helper. A new+  `captureLookup(rawURL:captureTitle:includeCharacters:)` attaches the matched+  work's cast to an `.edit` basis inside the same shared read that decided the+  disposition (Q5); a failed read leaves the basis as a characterless work+  leaves it and logs the cause at debug level (Q15, Q17). The flag is a separate+  overload rather than a default argument because a protocol witness must match+  the requirement's full name (Q16): every existing caller, `PendingCaptureDrain`+  included, still reads no characters (Q10), and only `CaptureCoordinator` opts+  in. `ReShareEditState.characters` is copied from the basis in+  `loadWithLookup`, and the `.stale` branch of `submitUpdate` carries the+  previous list forward because the commit path walks no characters (Q11).+  `lookupDisposition`, `reShareBasis(hostname:)`, the commit race guard and the+  `.stale` bases are untouched. Seven AsterismCore tests cover the lookup with+  and without the flag and the edit state across a stale refresh and a failed+  save. No view work yet.++- **Share character projection and repository read (share-sheet-characters,+  T-1916, phase 1: Core projection).** The value the capture sheets will draw,+  and the read that produces it; no extension or view-model wiring yet.+  `ShareCharacter` (name, aliases) and `ShareCharacterRow.text(for:)` render+  `Characters: Alice (Al, Ally), Bob` — nil for an empty list — in AsterismCore,+  so the extension lays text out rather than deciding it (Q6).+  `sortedCharacterGroups(_:)` is the one order a work's characters are listed+  in (normalised name, UUID tie-break, Q9); `characterPresentations` now maps+  over it and drops its own sort, the work page's order unchanged.+  `LibraryRepository.shareCharacters(workID:)` is one shared-lock read that+  fetches the Work rows by predicate — an unknown id is an empty cast, not an+  error — and maps torn characters to their presented content, leaves orphans+  out, and unions same-UUID duplicate Work rows exactly as the work page does+  (Q7). Nine AsterismCore tests cover the row text and the read, including+  order parity with `workDetail(id:)`.+ - **Characters extracted from your notes, reviewed by you (character-extraction,   T-2229, app integration).** The feature is now wired end to end. On devices   with Apple Intelligence, a background sweep (bounded: two works per
specs/share-sheet-characters/decision_log.md Modified +30 / −0
diff --git a/specs/share-sheet-characters/decision_log.md b/specs/share-sheet-characters/decision_log.mdnew file mode 100644index 0000000..3f9cbb5--- /dev/null+++ b/specs/share-sheet-characters/decision_log.md@@ -0,0 +1,30 @@+# Decision Log: Share Sheet Characters++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-23 | Show every character of the work, in the work page's name order; no cap, no "main character" ranking | The schema has no prominence, pin or role field, and the reader chose the full list over a derived top-N or a reader-pinned flag (the latter would mean schema V8, a new archive generation and a CloudKit publish) |+| Q2 | 2026-08-23 | Name and aliases only — no facts, notes or fact counts | Facts cite entries anywhere in the work, including chapters ahead of the one being shared; `docs/asterism-v2-plan.md` parks spoiler boundaries as a product decision and V7 encodes none. Names and aliases carry no spoiler and keep the note editor near the top of the sheet |+| Q3 | 2026-08-23 | Shown on the re-share arm and on the new-capture arm whenever `projectedWorkID` is set (`.reuse` and `.claim` assignments); nothing for `.create`, `.ambiguous`, the saved-for-later and message arms | A claim is an existing nil-identity work matched by title (`LibraryRepository+OutcomeComputation.swift`, `captureWorkMatch`) and has characters exactly as a reuse does; `projectedWorkID` is the field the commit path already keys on |+| Q4 | 2026-08-23 | A narrow character read rather than `workDetail(id:)` | `workDetail` builds every chapter row, the work snapshot and the type directory under the lock; the sheet needs name and aliases only |+| Q5 | 2026-08-23 | Re-share arm: characters ride along in `captureLookup`'s locked read, on the basis. New-capture arm: a follow-up `shareCharacters(workID:)` read keyed by the projected work id, awaited by the bridge after the sheet renders | The lookup already has the carrier's work in hand, so the re-share arm gets one read, no bridge change and no late fill. The projection is recomputed per keystroke and `CaptureOutcome` is compared at commit, so characters cannot live there; a read keyed by work id runs once per work, not per keystroke |+| Q6 | 2026-08-23 | One wrapping text row (`person.2` glyph, `Characters: Name (alias, alias), Name`) in the metadata card's existing row style — no pills, no flow layout; the visible text carries the `Characters:` prefix like the sibling `Work:`/`Chapter:` rows | `FlowLayout` lives in the app target (`TeachingComponents.swift`), the sheet's other metadata is prefixed text rows, and a text row is the shortest presentation of a full list |+| Q7 | 2026-08-23 | Torn characters show their presented content with no marker; orphans are invisible | The extension can offer no resolution; `characterRows(of:)` cannot reach an orphan, matching the work page |+| Q8 | 2026-08-23 | `characters` is a field on `ReShareEditBasis` rather than a new payload on the `.edit` disposition | The basis already carries display-only fields (`title`, `firstCapturedAt`) that the commit's staleness comparison ignores; changing the disposition's shape would touch every pattern match for no gain |+| Q9 | 2026-08-23 | A `sortedCharacterGroups(_:)` helper orders `CharacterGroup`s once; both `characterPresentations` and the share list map over it | The existing comparator sorts `WorkCharacterPresentation` values, so it cannot be shared with a list of another type; sorting the groups before either mapping gives one order for both surfaces |+| Q10 | 2026-08-23 | The character walk is opt-in on the repository lookup (`includeCharacters: Bool = false`); only the extension's coordinator opts in | `PendingCaptureDrain` calls the same `captureLookup` for every spooled record and uses only the entry id and identity key; `characterGroups` canonicalises each row's `factsData` through JSON, which is not free to pay for a discarded result |+| Q11 | 2026-08-23 | A `.stale` re-share refresh carries the previously shown characters forward rather than having `commitReShareUpdate` walk them | The commit path runs under the exclusive lock and its basis exists for the staleness baseline; the cast cannot have changed in a way the sheet needs to reflect between two taps of Update |+| Q12 | 2026-08-23 | The new-capture fetch is guarded by work id (`inFlightWorkID`, publish only if the projected id still matches), not by `CaptureViewModel.generation` | `generation` advances on every keystroke; guarding on it would discard any fetch that began before the reader started typing, and the list would never appear |+| Q13 | 2026-08-23 | The new-capture bridge triggers the character read from `ObservableCaptureViewModel.refreshState()` | It is the one point every awaited view-model call passes through, including the initial load from `ShareCaptureRootView` and the post-save re-projection, so no caller list can go stale |+| Q14 | 2026-08-23 | A `.claim` work row keeps its unlabeled `Work: X` text even though a characters row now sits under it | Labelling claims "(existing)" is sheet wording outside this ticket; the characters row is still correct for a claim because the work exists |+| Q15 | 2026-08-23 | A failed read logs its cause at debug level via `captureLogger`; nothing reaches the reader | Matches the undecodable-rule precedent in `captureLookup`; reasons are always readable in diagnostics, reader content is not |+| Q16 | 2026-08-23 | `includeCharacters` is a separate three-parameter `captureLookup` overload with no default value; the two-parameter method forwards `false` | A protocol witness must spell the requirement's full name, so a defaulted third parameter cannot satisfy `LibraryProviding`/`LookupCaptureCoordinating`'s `captureLookup(rawURL:captureTitle:)`. Net behaviour is the smolspec's: every existing caller (the drain included) reads no characters, `CaptureCoordinator` opts in |+| Q17 | 2026-08-23 | The failed character read is swallowed with `do`/`catch`, not `try?` (both arms) | Q15 wants the cause logged at debug level; `try?` discards it. `captureLogger` is file-private to `LibraryRepository+Capture.swift`, so `CaptureViewModel` logs its own line through a logger of the same shape |+| Q18 | 2026-08-23 | `loadCharactersIfNeeded` checks for a nil projected work id *before* the `charactersWorkID`/`inFlightWorkID` guards | The smolspec's literal order never reaches the clear: when the outcome loses its work id and no read is in flight, `target == inFlightWorkID` (both nil) returns early and the stale cast stays on the sheet, contradicting the requirement that the row clears |+| Q19 | 2026-08-23 | `inFlightWorkID` is released after the await on every return path, but only when it still names the returning read's work (`if inFlightWorkID == target`) | A read superseded before it returned must not block a later read for the same work, and a newer read's marker must not be clobbered. Two concurrent reads for one work (X→Y→X) can both publish the same list — redundant, never wrong |+| Q20 | 2026-08-23 | The characters row aligns glyph and text on `.firstTextBaseline` and pins the text with `.fixedSize(horizontal: false, vertical: true)`, unlike the single-line sibling rows | The row is the only one required to wrap; a centre-aligned glyph against a multi-line block reads wrong. Spacing, glyph size, colour and font match the siblings |+| Q21 | 2026-08-23 | The bridge's `refreshState()` spawns the character read only when the projected work id differs from both `charactersWorkID` and `inFlightWorkID` (and, with no projected work, only while either is set) | Comparing against the loaded id alone would re-spawn on every refresh while a read is in flight, each spawn returning at once and refreshing again — a spin until the read lands. The in-flight guard settles every case in at most one follow-up refresh |+| Q22 | 2026-08-23 | The bridge publishes `CaptureViewModel.displayedCharacters` — `characters` only while `charactersWorkID` still names the projected work — rather than `characters` itself | The publish rule belongs in the model, where `CaptureStateTests` covers it. The bridge's unconditional copy ran before the read for the new work, so a projection moving from work A to work B (or to `.create`) drew A's cast under B's title until the read landed |+| Q23 | 2026-08-23 | `loadCharactersIfNeeded` returns whether it published, and `needsCharacterLoad` holds the "needs a read" predicate; the bridge refreshes only on a `true` | One owner for each rule: the bridge had re-derived the guard from `charactersWorkID`/`inFlightWorkID`, which the read already applies, so the two could drift. This replaces the bridge-side copy of Q21's guard — a no-op read no longer refreshes, so the spin cannot occur even without the check, and the check stays only so a refresh per keystroke does not spawn a task per keystroke. Both ids drop to `internal` |+| Q24 | 2026-08-23 | `lookupDisposition(fromGroups:hostname:identityKey:)` takes the groups; `captureLookup` derives them once and passes them to it | The opt-in character walk needed the matched group's work id and re-derived `entryGroups` over the same rows the disposition had just been decided from. The `from: [Entry]` signature stays for the commit race guard, forwarding to the new one |

Things to double-check

Work change on the new-capture sheet.

Type a title that matches existing work A, wait for the row, then edit the title to match existing work B. The row should blank, then show B's cast. It must never show A's cast under Work: B.

Re-share twice fast.

Update a note, then Update again before the sheet dismisses. The second tap's .stale refresh should keep the characters row as it was (Q11).

Drain untouched.

PendingCaptureDrain.swift:498 still calls the two-parameter captureLookup; the 32 drain tests passed. If the drain is ever switched to the three-parameter form, it must pass false.

Five changelog entries for one feature.

Per-phase entries are this repo's [Unreleased] convention. At release-prep, collapse to one reader-facing Added line and drop the phase-5 'bar passed' entry — it is process, not a change.