asterism branch T-2229/character-extraction commits 33 files 150 touched lines +18,348 / -580 suites core + quick + UI green

Pre-push review: character-extraction (T-2229)

On-device character extraction end to end: schema V7 with reader-owned Character/CharacterSuppression records, a grounded extraction pipeline behind a shared single-slot model lane, a decision-gated review flow, full duplicate/torn/sync/archive integration, and archive generation 6/7. Reviewed by four parallel agents (reuse, quality, efficiency, spec & docs); every actionable finding fixed on-branch before push.

At a glance

  • What ships: the phone's on-device model reads a work's notes and proposes characters — every fact a verbatim quote citing its source — held until the reader accepts them fact-by-fact; accepted characters are fully editable, combinable, synced, archived (new generation 6/7), and torn/duplicate-handled like all reader data.
  • Review outcome: 4 agents, ~27 findings; all fixed except four consciously skipped (perf measurement, a latent shared log-privacy pattern, a low-risk test-gap tail, a semantics-differ dedup).
  • Decision log: Q99–Q112 record every post-implementation verdict; two new rows (Q111, Q112) came out of this review.
  • Before a second device syncs dev: run the Development app once so CloudKit publishes the two new record types (Q86) — user-side step, still open in prerequisites.md.
  • Not yet measured: characters joining the Req 10.1 publication budget is by-construction only; make test-performance-m4 (~20 min, host-only) is the follow-up.

Verdict

Ready to push

All four review agents returned; no requirement is missing and no decision-log entry is contradicted. Every actionable finding — one drift-risk duplicate of the matching tiers, two spec deviations, the interactive-path whole-table fetches, a double store projection on export, and a set of consolidation cleanups — was fixed and committed (1c3fca6, 80bb9f8, plus doc commits). make test-core, make test-quick, and make test-ui (95/0, 2 skipped) all pass on the final state; builds are warning-free on touched files. Remaining items are consciously deferred and listed under findings as skipped.

Review findings

19 raised · 15 fixed · 4 skipped

Jump to findings →

Commits

Three-level explanation

What this does

Asterism is an app where a reader keeps notes about works they read. This branch makes the phone itself read those notes and suggest the characters in them — like a librarian drafting an index card per character, where every statement is an exact quote from the reader's own note, linked to the note it came from.

Three trust rules

  • Everything is a quote. If the exact words are not in a note, the fact is dropped before the reader sees it. The model cannot paraphrase into the record.
  • Nothing is saved until approved. Suggestions wait in a review sheet with per-fact ticks and per-alias strikes; skipping is remembered durably so the same name is not re-proposed.
  • Everything stays on the phone. Apple's on-device model, only in good conditions (not Low Power, not hot), bounded per app activation.

Why it matters

The reader's notes already say who everyone is — spread across dozens of entries. This collects that into one place per work without inventing anything the reader didn't write. Accepted characters become normal reader data: shown on the work page, editable, combinable, deletable, synced, and in backups.

Three layers

  • AsterismCore — schema V7 (V6 frozen; Character, CharacterSuppression, per-source coverage fingerprints), the repository surface (one locked-context candidates read; commit-gated decisions; a staged edit step for create/edit/delete/combine; merge/delete integration), characters as a third family in the duplicate/torn machinery, and archive generation 6/7 that still imports 4/4 and 5/6.
  • AsterismIntelligenceModelLane (app-wide single-slot arbitration; interactive asks a background holder to yield, never seizes), the guided-generation extraction client (greedy), mechanical grounding (verbatim/name-presence/caps; over-long dropped, never truncated), assembly (slash-split into name + proposed aliases; one CharacterMatching implementation), and the ledger (held proposals merged per name key per work; manual passes never charge the sweep budget).
  • App target — the extraction coordinator beside the rule-suggestion one, the review sheet, work-page Characters section with staged edit-mode ops, entry-detail citations, torn-character resolution arms, Settings backup on 6/7.

Patterns

TDD throughout (~15 new suites; stub-driven UI journeys, no live model outside the package's guarded checks). The pipeline writes nothing the reader didn't approve — held proposals are in-memory per-run; durable writes are decisions, coverage fingerprints, and suppressions. Anything sync can compare is canonical and single-sited: one name-key recipe, one canonical-JSON formatting constant, one SHA-256 helper, one fact row-ID.

Trade-offs

Parallel coordinators sharing extracted parts (ModelLane, SweepGate, PipelineLog, withAttemptTimeout) instead of a premature generic framework. UUID-only duplicate sets make silent merge structurally unreachable — combining is always the reader's act. Per-source model requests bound context and keep citations mechanical at the cost of more requests. Suppressions accrete (clears are status flips) for simple LWW convergence; the review kept that growth off every interactive read path.

Commit-time semantics

commitCharacterDecision re-validates in-transaction: stale fingerprints, re-routed matches (displayed target verified against a fresh resolution; mismatch refuses with reRouted(to:) which genuinely retargets the held proposal and refreshes the sheet, Q66/Q110), and tornness. The edit step verifies a character's basis on first touch only (Q108) — combine-then-tidy commits while external change still refuses; the derived update applies only draft-changed fields so a combine's moved facts survive.

Scheduling and ordering

Sweep: two works per activation by recency, skipping works whose uncovered sources are all attempted this run (Q106); stop signal checked per source (Q110); manual passes are budget-exempt, coverage-blind, and dedup against accepted facts. Facts carry exactly two orders: canonical (encode/merge/dedup, Q75) and capture order (every display surface, Q88) — the review sheet now derives capture order from the same locked read as the work page, so the surfaces cannot disagree.

Archive and sync edges

Generation 6/7 exports from a single group projection pass; suppressions export unfolded (Q82's convergence needs the rows); coverage is self-validating (Q81); dangling citations are tolerated (Decision 2) while torn characters refuse export on every generation's path (Q105). modifiedAt is the import value guard, which is why collapse repointing clamps it monotonically — a backwards stamp would let an older archive overwrite a newer character.

Watch items

  • Req 10.1 participation is by-construction, unmeasured — run make test-performance-m4 before trusting the budget.
  • The unscoped sweep read is whole-library by design (Q78) — the knob to revisit at scale.
  • FoundationModelDiagnostics.describe interpolates String(describing:) into an always-public log field — no reader content today, a latent risk shared with rule suggestion.
  • Q86: the Development app must run once post-migration before a second device syncs the dev CloudKit container.

Important changes — detailed

AsterismSchemaV7: characters enter the data model

Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift

Why it matters. The migration chain grows a stage; every sync/archive/duplicate behaviour downstream hangs off these two entities and the coverage fingerprints being CloudKit-safe (defaulted/optional, no uniques, .nullify inverses).

What to look at. AsterismSchemaV7.swift (new), AsterismSchemaV6.swift (frozen), LibraryRepository+Bootstrap.swift marker/state changes

Takeaway. The six-row schema-bump checklist (freeze, add, plan, marker, bootstrap state, extension) is followed verbatim — the agent-note that documents it was updated in the same branch, so the next bump starts from truth.
Rationale. Plan stays [V5, V6, V7] with two lightweight stages (Q80); the Swift type is CharacterRecord because the stdlib owns Character, while entity, CloudKit record type, and archive keys stay "Character".

ModelLane: one slot for every on-device model call

Packages/AsterismCore/Sources/AsterismIntelligence/ModelLane.swift

Why it matters. Two features now compete for one Apple Intelligence session; without arbitration they'd interleave and both degrade. Rule suggestion adopted the lane in the same branch.

What to look at. ModelLane.swift (actor + LaneToken), RuleSuggestionCoordinator adoption, CharacterExtractionCoordinator usage

Takeaway. Interactive claims ask a background holder to yield and then wait for the release — a two-step handoff, never a seizure — so a background attempt always settles cleanly. Token release is triply covered (explicit, release(), deinit).
Rationale. Q62/Q77: one production lane app-wide via ModelLane.shared; a claim's model clock starts only once the lane is held so a lane wait charges no budget (Q68).

Grounding: facts exist only as verbatim quotes

Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift

Why it matters. This is the feature's trust contract: the model can only select from the reader's own words, never paraphrase into the record.

What to look at. CharacterGrounding.swift, CharacterExtractionAssembler.swift (slash-split, matching, canonicalisation)

Takeaway. Host-side mechanical checks (substring presence, name presence, caps with drop-not-truncate) turn an LLM output into evidence-bounded data — the citation is which source was sent, never model-emitted.
Rationale. Q14/Q15 (mechanical grounding), Decision 5 (slash-compound splitting), Q79 (resolved-key canonicalisation before dedup/suppression).

Decision commits: gates re-validate inside the transaction

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift

Why it matters. The reviewed snapshot can be stale by commit time (sync, sibling accepts). The gates — stale fingerprint, re-routed match with displayed-target verification, tornness — are what keep an accept from landing on the wrong character.

What to look at. commitCharacterDecision, resolvedTarget (now delegating to CharacterMatching.match), suppression LWW writes

Takeaway. A refusal path must do something: the reRouted(to:) payload retargets the held proposal and the sheet re-reads, so the reader sees a bundle, not a loop. This review found and fixed the second, drifting copy of the matching tiers.
Rationale. Q66 (displayed-target verification), Q82 (suppression LWW, cleared-wins then lowest-UUID), Q110 (refusal refresh made real).

Edit step: first-touch basis verification

Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift

Why it matters. Staged create/edit/delete/combine apply as one transaction; the naive per-op basis check made combine-then-tidy structurally uncommittable and blamed a phantom concurrent editor.

What to look at. commitCharacterEdits, CharacterEditBasis, the touched-set threading, deletingOmitted: fact application

Takeaway. A basis check exists to catch external change, not the session's own writes — verify on first touch per record, then trust the transaction's intermediate state.
Rationale. Q108, recorded after the app-phase critic review; Q97 keeps the session discardable until commit.

Archive generation 6/7 and the Settings switch

Packages/AsterismCore/Sources/AsterismCore/BackupV6Exporter.swift

Why it matters. Characters survive backup/restore or the feature is a data-loss risk (Req 6.1). The exporter now runs one store projection (this review removed a full second pass), and the Settings surface actually ships 6/7 — a gap the phase review caught: no task covered it.

What to look at. BackupV6Types/Codec/Exporter, BackupImportCharacters, SettingsBackupModel retyped on BackupV6Metadata/BackupV6ExportError

Takeaway. Frozen-generation archives mean a new generation is a new type family — but the projection layer underneath can and should be shared; the dead characters field this review found was the symptom of not consuming it.
Rationale. Q81 (self-validating coverage), Decision 2 (dangling-citation tolerance), Q105 (torn refusal on every generation's path).

Sweep scheduling: bounded, attempt-aware, per-source stoppable

Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift

Why it matters. The background sweep must never hog the device or wedge on one work. The app-phase review found the slot-starvation and per-work-only stop bugs; both fixes are load-bearing here.

What to look at. CharacterExtractionCoordinator (activation sweep, manual pass, reconcile), CharacterExtractionLedger (held merge, budgets, SweepGate)

Takeaway. Eligibility must consult attempt memory, not just durable coverage — a work whose sources all refused would otherwise occupy a slot every activation for the rest of the run.
Rationale. Q106 (attempt-aware slots), Q110 (per-source stop), Q101 (manual passes never charge the sweep budget), Q69 (cancellation not attempted).

Key decisions

Q99 — one name-key recipe, repeat-until-stable article strip.

The integration review found two divergent implementations (a live Req 1.7 defect: accepted facts re-proposed forever). Collapsed onto AsterismCore's CharacterNameKey.normalize; idempotence is load-bearing because Q91 stores a bare retained key as an alias that gets re-normalised.

Q101 — manual passes never charge the sweep budget.

The design's error table said "manual unaffected"; the code charged both, so one manual pass over a large work could silently kill the automatic sweep for the rest of the run.

Q106 — sweep eligibility consults attempt memory.

Coverage-only eligibility let a work with held-undecided or all-refused sources occupy one of the two activation slots forever. Mirrors the rule-suggestion precedent.

Q108 — basis verified on first touch within an edit step.

Later operations in the same step trust the transaction's intermediate state; a basis check catches external change, not the session's own writes. Without this, combine-then-edit could never commit.

Q109 — review sheet unreachable in edit mode.

The sheet's completion reload rebuilds drafts and would silently destroy the staged session Q97 promises is discardable only by the reader's explicit act.

Q111 — character conflicts surface as typed refusals.

Recorded by this review: the design's audit table named recordConflict(.character), but the character flows own a refusal channel with better wording. Behaviour (Req 2.8/5.3) unchanged and tested.

Q112 — generic-notes citations render as inert labels.

Recorded by this review: Req 5.2's letter says navigable, but the generic notes live on the very page showing the fact; navigation would go nowhere useful.

Candidate skip suppresses name keys only (spec restoration).

This review found applySkip suppressing the displayed facts' identity triples on every skip — beyond AC 2.2/2.4, and it would silently omit facts from a later bundle after a Q44 hand-create. Fixed to: candidate skip → name keys; bundle skip → triples. No new decision needed — the spec already said this.

Review-sheet fact order is capture order (spec restoration).

The assembler ordered a merged row's facts by entry UUID and its comment mis-claimed canonical order was also the display order. Fixed at the display layer via a captureOrder map derived from the same locked read the work page uses; canonical order stays for encode/merge/dedup.

Review findings

SeverityAreaFindingResolution
majorLibraryRepository+CharacterExtraction resolvedTargetSecond implementation of the Req 2.3 matching tiers + tie-break; commit-side copy untested — drift would route one proposal onto two characters (the exact Q66 failure mode).Delegates to CharacterMatching.match via an extracted matchTarget helper; target resolved once per commit; commit-side precedence/tie-break test added.
majorInteractive read pathsWhole-table CharacterRecord/CharacterSuppression fetch-then-filter at five interactive sites (work-page open, every review tap, edit commit, entry detail, merge preview) plus the scoped candidates branch — cost scales with the library's whole character population, which accretes by design.All six sites derive from the fetched work rows via inverse relationships (the repoint pattern); sync-orphan semantics verified identical; the unscoped sweep read keeps Q78's whole-library breadth.
majorReview sheet fact order (AC 2.1/Q88)Merged rows displayed facts in entry-UUID order, not capture order, behind a comment claiming the spec said otherwise; the work page disagreed with the sheet.Display-layer capture ordering from the shared locked read; comment corrected; test added pinning capture order on a merged row.
moderateapplySkip (AC 2.2/2.4)Candidate skips suppressed the displayed facts' identity triples beyond spec — a later Q44 hand-create would silently omit those facts from the next bundle.New-candidate skips suppress displayed name keys only; bundle skips keep triple suppression; the pinning-nothing test now passes facts and asserts both paths.
moderateBackupV6ExporterV6 export projected the store roughly twice (payload + characters re-fetch + coverage re-derivation) while the characters field added to BackupGroupProjection had zero consumers — every export paid a dead sort.projectV5Payload returns the common projection alongside the payload; V6 characters and coverage derive from it; one projection pass, dead sort now live.
moderateDesign audit table (conflict routing)No caller passes .character to recordConflict; character conflicts surface as typed refusals instead — behaviourally fine, but the substitution was unrecorded.Recorded as Q111.
moderatedocs/agent-notes/schema-migration.mdStated V6 live, plan [V5, V6], marker "6", backup 4/4-only — stale on every axis after this branch, and it is the checklist the next schema bump reads first.Current state rewritten to V7 reality; superseded facts moved to History; the next-bump table now includes an archive row.
moderateWorkDetailModel session handlingsaveAllEdits captured and restored the staged character session around save()'s reload (invariant at the caller); double full reload when metadata and character edits both changed; stagedCharacterEdits computed twice per commit.adoptCharacterDrafts now refuses to clobber an active session; save(reloading:) skips the redundant reload; edits computed once.
minorManual-pass stateView kept its own isRunningManualPass while the coordinator's .running outcome mapped to EmptyView; clearManualOutcome had no production caller, so outcome labels persisted all run.UI driven from the coordinator outcome; outcome cleared on review-sheet open and new-pass start.
minorLog plumbingCharacterExtractionLog cloned RuleSuggestionLog's ~45 lines including the load-bearing #if DEBUG privacy split; CharacterReviewModel additionally minted a raw Logger bypassing the split.Shared PipelineLog with two thin façades; the raw Logger removed.
minorAsterismIntelligence twinsTimeout-race machinery duplicated between the two attempt actors; the ~40-line sweep-generation state machine duplicated between the two ledgers; currentEnvironment duplicated between the two coordinators.withAttemptTimeout helper, SweepGate value type, SuggestionEnvironment.modelWorkEnvironment extension — public APIs unchanged, ledger suites pass unmodified.
minorCanonical primitivesFour spellings of SHA-256-lowercase-hex; three copies of the canonical-JSON output formatting (byte-identity across devices is a false-tear risk); the fact display-row-ID recipe spelled in two layers.One Hexadecimal helper, one OutputFormatting.canonical constant, one CharacterFact.displayRowID — encoded bytes verified identical by the fingerprint/codec suites.
minorSmall correctness hygieneDead Character.matchKeys; character?.id ?? id ?? UUID() fallback that would silently no-op bindings; two-valued CollapsedRecordType ternary computing .work for a character; non-sorting sortedAliases local; stale "6" prose in bootstrap test comments.Deleted / non-optional id parameter / exhaustive switch / renamed / comments corrected.
minorCLAUDE.md, OVERVIEW, CHANGELOGCLAUDE.md said one live Apple Intelligence call (now two) and omitted the CharacterExtraction log category; the overview body still said Planned with 25 tasks; the changelog claimed no test runs a live model.All three corrected; Q86/backup prerequisites now flagged in the overview body.
minorTest gaps (high-value subset)Commit-side matching untested; AC 2.5's fact-suppression clear on accept untested; skip-scope pinned nothing.Three tests added with the corresponding fixes; remaining low-risk tail deliberately skipped (see below).
minorReq 10.1 participationCharacters joined the duplicate-scan/publication path with the mandated gates, but no perf run measures the added cost against the budget.Deferred: run make test-performance-m4 (~20 min, host-only) as a follow-up; single runs are not baselines per the project's own measurement notes.
minorFoundationModelDiagnostics.describeString(describing: GenerationError) lands in an always-public log field — no reader content today, latent Req 1.9 risk shared with the pre-existing rule-suggestion pattern.Deferred: a shared fix belongs in its own change touching both features' precedent.
minorRemaining test-gap tailQ82's third tie-break leg, reconciler ledger-retain pin, two-row combine target, 4/4 torn-export pin, structural-only UI render pins.Deliberately skipped as low-risk; listed in implementation.md's watch items.
minorAlias-union loopThe append-unless-key-taken loop appears four times with differing seed sets.Skipped: per-site semantics genuinely differ; a forced abstraction would obscure them.

Per-file diffs

Click to expand.

Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift Added +482 / -0
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift b/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swiftnew file mode 100644index 0000000..97438f3--- /dev/null+++ b/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift@@ -0,0 +1,482 @@+import AsterismCore+import AsterismIntelligence+import Foundation++/// Owns everything an extraction pass needs that is not the attempt itself: the+/// ledger, the in-flight `Task`, the library reads, the coverage write, and the+/// outcome the reader is shown after a manual pass.+///+/// `@MainActor` because the work page reads `held(for:)` and the manual-pass+/// outcome synchronously; the attempt runs in a `CharacterExtractor` actor, so+/// no model work touches this actor.+///+/// The shape is `RuleSuggestionCoordinator`'s deliberately — the two features+/// share the lane, the environment gates and the log split, and a second shape+/// for the same job would be a second set of bugs.+@MainActor @Observable+final class CharacterExtractionCoordinator {++    /// What a manual pass ended with (Req 1.11). Every ending the reader can see+    /// is one of these three: nothing else — a refusal, a timeout, an empty+    /// answer and a work that vanished all read as "no proposals available",+    /// because Req 1.8 forbids any of the causes reaching them.+    nonisolated enum ManualPassOutcome: Equatable, Sendable {+        case running+        case proposals(Int)+        case noProposals+    }++    /// Req 1.11: the manual trigger is not offered while the model is away.+    /// Re-read on every activation, because `.modelNotReady` is transient.+    private(set) var isModelAvailable: Bool++    /// The manual outcome per work, so the work page can show it and dismiss it+    /// without asking the coordinator to remember which page is open.+    private(set) var manualOutcomes: [UUID: ManualPassOutcome] = [:]++    private let library: any LibraryProviding+    private let model: any CharacterExtractionModelClient+    private let extractor: CharacterExtractor+    private let environment: any SuggestionEnvironment+    /// The app-wide model lane (Req 1.3, Q62). The ledger arbitrates this+    /// feature's own passes against each other; the lane arbitrates against rule+    /// suggestion's model work.+    private let lane: ModelLane++    private var ledger = CharacterExtractionLedger()+    /// The attempt is the coordinator's, never the caller's: a view that goes+    /// away must not cancel work the ledger has already recorded.+    private var attemptTask: Task<SourceOutcome, Never>?+    private var sweepTask: Task<Void, Never>?++    init(+        library: any LibraryProviding,+        model: any CharacterExtractionModelClient,+        environment: any SuggestionEnvironment = SystemSuggestionEnvironment(),+        lane: ModelLane = .shared,+        timeout: Duration = CharacterExtractionBounds.attemptTimeout+    ) {+        self.library = library+        self.model = model+        self.extractor = CharacterExtractor(model: model, timeout: timeout)+        self.environment = environment+        self.lane = lane+        self.isModelAvailable = model.availability().isAvailable+    }++    // MARK: - Reads++    /// The rows the review sheet shows for one work (Req 2.1).+    func held(for work: UUID) -> [ExtractionProposal] { ledger.held(for: work) }++    /// Whether the work's page shows the proposals indicator (Req 2.1).+    func hasProposals(for work: UUID) -> Bool { !ledger.held(for: work).isEmpty }++    var worksWithProposals: Set<UUID> { ledger.worksWithProposals }++    /// Req 1.11: hidden, not disabled — an action that can never do anything is+    /// not an action.+    var canRunManualPass: Bool { isModelAvailable }++    func manualOutcome(for work: UUID) -> ManualPassOutcome? { manualOutcomes[work] }++    func clearManualOutcome(for work: UUID) { manualOutcomes[work] = nil }++    /// What the run has spent on the sweep (Req 1.2). A manual pass charges+    /// nothing (Q101).+    var budgetSpent: Duration { ledger.budgetSpent }++    // MARK: - Decisions++    /// Drops the row a committed decision decided. One row per name key per work+    /// (Q83/Q100), so the key identifies it.+    func discard(nameKey: String, for work: UUID) {+        ledger.discard(nameKey: nameKey, for: work)+    }++    /// Q66/Q110: applies a `.reRouted` refusal's payload to the row it was+    /// about, so the refreshed sheet re-presents it against the character it+    /// really resolves onto rather than re-reading the row that just refused.+    func retarget(nameKey: String, for work: UUID, to characterID: UUID?) {+        ledger.retarget(nameKey: nameKey, for: work, to: characterID)+    }++    // MARK: - The activation sweep (Req 1.1, 1.2)++    /// One sweep per activation, bounded by `worksExamined` at the read and+    /// `worksPerActivation` at the model. Never awaited inline by the caller —+    /// the app's activation must not wait on model work (Req 1.2).+    ///+    /// Activations queue rather than collide, for the reason rule suggestion's+    /// do: `resignActive()` stops a sweep but cannot end it, so an app that comes+    /// straight back finds the previous coroutine still in its loop.+    func activationSweep() async {+        let previous = sweepTask+        let task = Task { [weak self] in+            await previous?.value+            await self?.runSweep()+        }+        sweepTask = task+        await task.value+        if sweepTask == task { sweepTask = nil }+    }++    private func runSweep() async {+        guard let generation = ledger.beginSweep() else { return }+        defer { ledger.endSweep(generation: generation) }++        let availability = model.availability()+        isModelAvailable = availability.isAvailable+        if case .unavailable(let reason) = availability {+            CharacterExtractionLog.note("sweep skipped — model unavailable: \(reason)")+            return+        }+        // Asked before the read, not after: with the budget spent every source+        // would be refused anyway, and the read is a whole-library fetch to be+        // told so.+        guard !ledger.budgetExhausted else {+            CharacterExtractionLog.note(+                "sweep skipped — run budget spent (\(CharacterExtractionLog.milliseconds(ledger.budgetSpent)) ms)")+            return+        }++        let candidates: [CharacterExtractionCandidate]+        do {+            candidates = try await library.characterExtractionCandidates(+                limit: CharacterExtractionBounds.worksExamined, workIDs: nil)+        } catch {+            // A library that is not ready yet skips this activation in silence+            // (Req 1.8); the next one tries again.+            CharacterExtractionLog.failure(+                "sweep skipped — candidate read failed: \(CharacterExtractionLog.describe(error))")+            return+        }++        // The read is already newest-activity-first and already excludes torn+        // works (Q53). What is left to bound is the model work.+        //+        // Q106: attempt memory joins coverage in the filter, exactly as rule+        // suggestion filters `!ledger.isAttempted` before its own prefix. A work+        // whose uncovered sources were *all* attempted this run — held undecided,+        // failed, or refused — is not going to produce anything this activation,+        // and on coverage alone it kept occupying one of the two slots on every+        // activation for the rest of the run, so the sweep never reached the work+        // behind it.+        let eligible = candidates+            .filter { candidate in+                candidate.uncoveredSources.contains { source in+                    !ledger.isAttempted(+                        ExtractionSourceKey(work: candidate.workID, source: source.ref))+                }+            }+            .prefix(CharacterExtractionBounds.worksPerActivation)+        CharacterExtractionLog.note(+            "sweep: \(eligible.count) of \(candidates.count) works have unattempted uncovered sources")++        for candidate in eligible {+            // A pre-emption or a resign-active ends *this* sweep; the next+            // activation restarts it.+            guard ledger.isSweeping(generation: generation) else { return }+            await process(candidate, pass: .automatic, sweep: generation)+        }+    }++    /// Req 2.8's invalidation hook, called after every library refresh — the+    /// same place rule suggestion's `reconcile()` is called from. The comparison+    /// is the ledger's; this only performs the read.+    func reconcile() async {+        let tracked = ledger.trackedWorks+        guard !tracked.isEmpty else { return }+        let rows: [CharacterExtractionCandidate]+        do {+            rows = try await library.characterExtractionCandidates(+                limit: tracked.count, workIDs: tracked)+        } catch {+            CharacterExtractionLog.failure(+                "reconcile skipped — candidate read failed: \(CharacterExtractionLog.describe(error))")+            return+        }+        var states: [UUID: WorkExtractionState] = [:]+        for row in rows {+            states[row.workID] = WorkExtractionState(+                revisions: row.revisionsBySource,+                characterIDs: Set(row.characters.map(\.id)))+        }++        let invalidated = ledger.reconcile(against: states)+        if !invalidated.isEmpty {+            CharacterExtractionLog.note("reconcile invalidated \(invalidated.count) work(s)")+            // A voided attempt's answer is discarded whatever it says; cancelling+            // only stops it sooner.+            attemptTask?.cancel()+        }+    }++    /// Req 1.2: the sweep stops with the foreground, and its attempt with it. A+    /// manual pass is the reader's and continues.+    func resignActive() {+        if ledger.resignActive() != nil { attemptTask?.cancel() }+    }++    /// Held proposals are re-derivable — their revisions are not covered — so+    /// they are the first thing to go under memory pressure (Q61).+    func memoryWarning() {+        if ledger.memoryWarning() != nil { attemptTask?.cancel() }+    }++    // MARK: - The manual pass (Req 1.11)++    /// The reader's pass over one work: every source regardless of coverage and+    /// suppression, starting even with the sweep budget spent, ending with a+    /// visible outcome.+    func runManualPass(workID: UUID) async {+        guard isModelAvailable else {+            CharacterExtractionLog.note("\(workID): manual pass refused — model unavailable")+            manualOutcomes[workID] = .noProposals+            return+        }+        manualOutcomes[workID] = .running++        let candidate: CharacterExtractionCandidate?+        do {+            candidate = try await library+                .characterExtractionCandidates(limit: 1, workIDs: [workID]).first+        } catch {+            CharacterExtractionLog.failure(+                "\(workID): manual pass — candidate read failed: \(CharacterExtractionLog.describe(error))")+            manualOutcomes[workID] = .noProposals+            return+        }+        guard let candidate else {+            // Gone, or torn — the read excludes torn works (Q53). Both read to+            // the reader as no proposals available.+            CharacterExtractionLog.note("\(workID): manual pass — the library returned no row")+            manualOutcomes[workID] = .noProposals+            return+        }++        await process(candidate, pass: .manual)++        let held = ledger.held(for: workID)+        manualOutcomes[workID] = held.isEmpty ? .noProposals : .proposals(held.count)+    }++    // MARK: - One work++    /// Every source of one work, in one pass, with the coverage its+    /// produced-none sources earned written once at the end (Q65).+    ///+    /// `sweep` is the generation of the sweep this pass belongs to, and nil for+    /// the reader's own pass, which no stop signal ends (Req 1.11).+    private func process(+        _ candidate: CharacterExtractionCandidate, pass: ExtractionPassKind,+        sweep generation: Int? = nil+    ) async {+        let context = CharacterExtractionContext(candidate)+        let revisions = candidate.revisionsBySource+        let sources = candidate.extractionSources(includingCovered: pass == .manual)+        var covered: [CharacterCompletedSource] = []++        for source in sources {+            // Q110: the stop signal is checked **per source**, not per work. A+            // work with several sources was otherwise a window of one attempt+            // timeout per remaining source in which `resignActive()` had stopped+            // the sweep and the sweep kept starting fresh requests anyway — and+            // after a manual pass pre-empted one source, the sweep retook the+            // slot on the next and the two cancelled each other in turn.+            //+            // Breaking rather than returning, so the sources already settled as+            // produced-none still earn their coverage below: they were processed,+            // and re-deriving them next activation would spend the budget to+            // produce the same nothing.+            if let generation, !ledger.isSweeping(generation: generation) {+                CharacterExtractionLog.note(+                    "\(candidate.workID): sweep stopped — remaining sources left for the next one")+                break+            }+            let outcome = await attempt(+                source, context: context, revisions: revisions, pass: pass)+            if outcome == .producedNone {+                covered.append(+                    CharacterCompletedSource(ref: source.source, fingerprint: source.fingerprint))+            }+        }++        guard !covered.isEmpty else { return }+        do {+            let written = try await library.advanceCharacterCoverage(+                workID: candidate.workID, sources: covered)+            CharacterExtractionLog.note(+                "\(candidate.workID): produced-none covered \(written) of \(covered.count) source(s)")+        } catch {+            // Coverage is bookkeeping: losing the advance costs the next sweep a+            // re-derivation the filter will empty again, never reader data.+            CharacterExtractionLog.failure(+                "\(candidate.workID): coverage advance failed — \(CharacterExtractionLog.describe(error))")+        }+    }++    /// How one source's attempt ended, in the only terms the caller acts on.+    private nonisolated enum SourceOutcome: Sendable, Equatable {+        /// Grounded and filtered to nothing: covers at pass time (Q65).+        case producedNone+        /// Rows the reader will decide on; the decision covers the source.+        case produced+        /// Failed, timed out, was refused, or never started. Left uncovered.+        case unsettled+    }++    private func attempt(+        _ source: ExtractionSource,+        context: CharacterExtractionContext,+        revisions: [SourceRef: String],+        pass: ExtractionPassKind+    ) async -> SourceOutcome {+        let key = ExtractionSourceKey(work: source.workID, source: source.source)+        let label = "\(source.workID)/\(CharacterExtractionLog.describe(source.source))"++        while true {+            switch ledger.start(+                key, fingerprint: source.fingerprint, pass: pass,+                environment: currentEnvironment+            ) {+            case .refuse(let reason):+                CharacterExtractionLog.note("\(label): \(pass.rawValue) refused — \(reason)")+                return .unsettled+            case .preempt(let preempted):+                // The two-step protocol: the ledger will not swap the in-flight+                // record itself, so the running attempt is cancelled and awaited+                // — it settles itself on the way out — and only then asked again.+                CharacterExtractionLog.note(+                    "\(label): manual pass pre-empts the attempt for "+                        + CharacterExtractionLog.describe(preempted.source))+                await cancelInFlight()+            case .start:+                CharacterExtractionLog.note("\(label): attempt start (\(pass.rawValue))")+                let task = beginAttempt(+                    key: key, label: label, source: source, context: context,+                    revisions: revisions, pass: pass)+                attemptTask = task+                let outcome = await task.value+                if attemptTask == task { attemptTask = nil }+                return outcome+            }+        }+    }++    private func beginAttempt(+        key: ExtractionSourceKey,+        label: String,+        source: ExtractionSource,+        context: CharacterExtractionContext,+        revisions: [SourceRef: String],+        pass: ExtractionPassKind+    ) -> Task<SourceOutcome, Never> {+        // Built here rather than inside the `Task` below: a `[weak self]` inside+        // the task body would re-capture the binding `guard let self` produced,+        // and a reference to *that* from a nested concurrently-executing closure+        // is an error under the Swift 6 language mode. Out here `self` is the+        // real one, and the capture is the ordinary kind.+        let yieldToInteractiveWork: @Sendable () -> Void = { [weak self] in+            // Bound to a `let` before the nested task: a weak capture is a *var*+            // in its closure's context, and referencing one from concurrently-+            // executing code is an error under the Swift 6 language mode.+            guard let coordinator = self else { return }+            Task { @MainActor in coordinator.attemptTask?.cancel() }+        }+        return Task { [weak self] in+            guard let self else { return .unsettled }+            let laneClass: ModelLaneClass = pass == .manual ? .interactive : .background+            let token: LaneToken+            do {+                // Acquired per request, and released between sources: a rule+                // editor waiting on the lane behind a manual pass waits for at+                // most one in-flight source (design, lane section).+                token = try await self.lane.acquire(laneClass, onPreempt: yieldToInteractiveWork)+            } catch {+                // **A lane wait is not a settlement**: no budget charge and no+                // attempt memory, so the source stays retriable this run.+                CharacterExtractionLog.note("\(label): lane claim withdrawn before the request")+                self.settle(key, .cancelled, modelPhase: .zero, label: label)+                return .unsettled+            }+            defer { token.release() }++            let started = self.environment.now+            do {+                let outcome = try await self.extractor.attempt(source)+                let proposals = CharacterExtractionAssembler.assemble(+                    outcome.candidates, revisions: revisions, context: context, pass: pass)+                self.settle(+                    key, .proposals(proposals), modelPhase: outcome.modelPhase, label: label)+                return proposals.isEmpty ? .producedNone : .produced+            } catch is CancellationError {+                self.settle(+                    key, .cancelled, modelPhase: self.elapsed(since: started), label: label)+                return .unsettled+            } catch let timeout as AttemptTimeout {+                self.settle(key, .timedOut, modelPhase: timeout.modelPhase, label: label)+                return .unsettled+            } catch {+                // Req 1.5's oversized source and Req 1.8's refusals and decode+                // failures differ only here, in the line that says which it was.+                if self.model.isContextWindowOverflow(error) {+                    CharacterExtractionLog.note(+                        "\(label): source too long for one request — skipped, left uncovered")+                } else {+                    CharacterExtractionLog.failure(+                        "\(label): request failed — \(self.model.describe(error))")+                }+                self.settle(key, .failed, modelPhase: self.elapsed(since: started), label: label)+                return .unsettled+            }+        }+    }++    private func settle(+        _ key: ExtractionSourceKey, _ settlement: ExtractionSettlement,+        modelPhase: Duration, label: String+    ) {+        CharacterExtractionLog.note(+            "\(label): settled \(Self.describe(settlement)) after "+                + "\(CharacterExtractionLog.milliseconds(modelPhase)) ms")+        ledger.settle(key, settlement, modelPhase: modelPhase)+    }++    /// The next attempt may not start until this one has stopped (Req 1.3).+    private func cancelInFlight() async {+        guard let task = attemptTask else { return }+        task.cancel()+        _ = await task.value+        if attemptTask == task { attemptTask = nil }+    }++    private func elapsed(since start: ContinuousClock.Instant) -> Duration {+        start.duration(to: environment.now)+    }++    private var currentEnvironment: ModelWorkEnvironment { environment.modelWorkEnvironment }++    private static func describe(_ settlement: ExtractionSettlement) -> String {+        switch settlement {+        case .proposals(let proposals):+            proposals.isEmpty ? "no proposals" : "\(proposals.count) proposal(s)"+        case .failed: "failed"+        case .timedOut: "timed out"+        case .cancelled: "cancelled"+        }+    }++    #if DEBUG+    /// Spends the run's whole sweep budget, so a test can drive the one rule+    /// that separates the two passes: exhaustion stops automatic work and leaves+    /// the reader's pass alone (Req 1.11, Q101). Charging the ledger by hand is+    /// the only way there — the alternative is a 120-second test.+    func exhaustBudgetForTesting() {+        let key = ExtractionSourceKey(work: UUID(), source: .genericNotes)+        _ = ledger.start(+            key, fingerprint: "", pass: .automatic, environment: ModelWorkEnvironment())+        ledger.settle(key, .cancelled, modelPhase: CharacterExtractionBounds.runTimeBudget)+    }+    #endif+}
Asterism/Asterism/CharacterExtraction/CharacterExtractionLog.swift Added +62 / -0
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterExtractionLog.swift b/Asterism/Asterism/CharacterExtraction/CharacterExtractionLog.swiftnew file mode 100644index 0000000..d056755--- /dev/null+++ b/Asterism/Asterism/CharacterExtraction/CharacterExtractionLog.swift@@ -0,0 +1,62 @@+import AsterismCore+import Foundation+import OSLog++/// The character-extraction pipeline's diagnostic log (Req 1.9).+///+/// Everything this pipeline declines to do is invisible to the reader by+/// design — a source skipped for being too long, an attempt refused by the+/// model's guardrails, a work passed over for being torn, and a sweep that ran+/// out of budget all look exactly like "no proposals" (Req 1.8, 4.3). These+/// lines are the only place the reason survives.+///+/// Follow one run in Console.app with+/// `subsystem:me.nore.ig.Asterism category:CharacterExtraction`, or after the+/// fact with+/// `log show --predicate 'subsystem == "me.nore.ig.Asterism" AND category == "CharacterExtraction"' --last 10m`.+///+/// The body is `PipelineLog`'s, shared with rule suggestion — the privacy split+/// Q23 asks for is the same split, and two copies of it are two chances for one+/// of them to start logging reader content in a release build. What lives here+/// is the category, the name a call site reads, and the one thing only this+/// pipeline has to describe: a source.+///+/// `nonisolated` for the same reason `RuleSuggestionLog` is: the extractor runs+/// on its own actor, off the main one, and every static under this target's+/// default isolation would otherwise be main-actor bound.+nonisolated enum CharacterExtractionLog {+    static let pipeline = PipelineLog(category: "CharacterExtraction")++    static var logger: Logger { pipeline.logger }++    /// `reason` — work id, source kind, counts, durations, refusal causes — is+    /// always readable. `content` — note text, evidence spans, proposed names+    /// and statements — is readable only in a debug build (Req 1.9, Q23).+    static func note(+        _ reason: @autoclosure () -> String,+        content: @autoclosure () -> String? = nil,+        level: OSLogType = .default+    ) {+        pipeline.note(reason(), content: content(), level: level)+    }++    static func failure(+        _ reason: @autoclosure () -> String, content: @autoclosure () -> String? = nil+    ) {+        pipeline.failure(reason(), content: content())+    }++    static func describe(_ error: any Error) -> String { PipelineLog.describe(error) }++    static func milliseconds(_ duration: Duration) -> Int64 {+        PipelineLog.milliseconds(duration)+    }++    /// A source in a log line: never its text, only which source it is.+    static func describe(_ source: SourceRef) -> String {+        switch source {+        case .genericNotes: "generic notes"+        case .entry(let id): "entry \(id)"+        }+    }+}
Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift Added +62 / -0
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift b/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swiftnew file mode 100644index 0000000..92252f2--- /dev/null+++ b/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift@@ -0,0 +1,62 @@+import AsterismCore+import AsterismIntelligence+import Foundation++/// One extraction attempt for one source: the model request, its bound, and the+/// grounding that follows it.+///+/// An actor, and deliberately not the main one — the model call must never run+/// on the actor the work page reads from (Req 1.2's "shall not delay app+/// activation" is the same rule stated from the reader's side). It owns no state+/// beyond its two dependencies; the ledger, the budget, the lane token and the+/// in-flight `Task` all live in the coordinator.+actor CharacterExtractor {+    private let model: any CharacterExtractionModelClient+    /// Injected so the timeout path is testable in milliseconds rather than in+    /// the 30 seconds `CharacterExtractionBounds` sets.+    private let timeout: Duration++    /// Main-actor isolated for the same reason `RuleSuggester`'s is, and at the+    /// same cost: under this target's `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`+    /// an actor's synchronous initializer cannot be made `nonisolated`. Only+    /// construction is pinned — the attempt itself is on this actor.+    init(+        model: any CharacterExtractionModelClient,+        timeout: Duration = CharacterExtractionBounds.attemptTimeout+    ) {+        self.model = model+        self.timeout = timeout+    }++    /// The grounded candidates one source produced, and what the attempt spent.+    ///+    /// Throws `AttemptTimeout` when the request outlived its bound and+    /// `CancellationError` when the app pre-empted it. Everything else the model+    /// can do wrong — a refusal, a decode failure, a source too long for one+    /// request — is thrown as it arrived and settled by the coordinator, because+    /// the three differ only in the log line (Req 1.8).+    func attempt(+        _ source: ExtractionSource+    ) async throws -> (candidates: [GroundedCandidate], modelPhase: Duration) {+        // Locals: `addTask`'s closure is not isolated to this actor, so reading+        // the stored properties inside it would need an `await` that the+        // closure's own suspension points make unnecessary.+        let model = self.model+        let timeout = self.timeout+        let started = ContinuousClock.now++        // The race itself is `withAttemptTimeout` — the same body `RuleSuggester`+        // bounds its pipeline with, so a timeout means the same thing on both.+        let result = try await withAttemptTimeout(timeout, startedAt: started) {+            try await model.extract(source)+        }++        let grounded = CharacterGrounding.ground(result, from: source)+        for drop in grounded.drops {+            CharacterExtractionLog.note(+                "\(CharacterExtractionLog.describe(source.source)): dropped a candidate — \(drop.reason.rawValue)",+                content: "name=\"\(drop.name)\"")+        }+        return (grounded.candidates, started.duration(to: .now))+    }+}
Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift Added +327 / -0
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift b/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swiftnew file mode 100644index 0000000..3209cfd--- /dev/null+++ b/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift@@ -0,0 +1,327 @@+import AsterismCore+import AsterismIntelligence+import Foundation+// For `OSLogType.info` on the one line below that asks for a level other than+// the default; the log body itself is `CharacterExtractionLog`'s.+import OSLog++// The review list (Reqs 2.1, 2.2, 2.5, 2.7, 2.8).+//+// Modelled on `PostTeachingWorkURLModel`'s queue: a list of independent+// decisions, each committed on its own, each failure keeping its own row and+// never touching the others.+//+// The one thing it does *not* share with that queue is refreshing. The sheet+// **snapshots its proposals at open** (Req 2.7): a sweep that settles while the+// reader is deciding must not move the list under them, so later results appear+// on the next open. The single in-place refresh is the commit-refusal+// disclosure, which is the case where what the reader is looking at is known to+// be wrong.++/// One proposed alias on a row, and whether the reader has struck it (Q92).+struct CharacterReviewAlias: Identifiable, Equatable, Sendable {+    let name: String+    var isStruck: Bool++    var id: String { name }+}++/// One proposed fact on a row, and whether it is still ticked (Req 2.2).+struct CharacterReviewFact: Identifiable, Equatable, Sendable {+    let id: String+    let statement: String+    let quote: String+    let source: SourceRef+    var isTicked: Bool+    let fact: CharacterFact+}++/// One row of the review list: a new candidate, or a bundle of additional+/// content for a character the work already has.+struct CharacterReviewRow: Identifiable, Equatable, Sendable {+    /// The proposal's name key. One row per key per work (Q83/Q100), so the key+    /// identifies the row for every decision that follows.+    let id: String+    let name: String+    let isBundle: Bool+    /// The character a bundle enriches, for the existing facts it is shown+    /// beside (Q38).+    let targetID: UUID?+    var aliases: [CharacterReviewAlias]+    var proposedFacts: [CharacterReviewFact]+    /// The target character's facts as they stand, so the reader can see what+    /// the bundle adds to (Req 2.1). Empty for a candidate.+    let existingFacts: [WorkCharacterFactRow]+    /// The held proposal this row was built from, so the decision request is+    /// assembled from the pipeline's own seam rather than re-derived here.+    let proposal: ExtractionProposal++    var struckAliases: Set<String> { Set(aliases.filter(\.isStruck).map(\.name)) }++    var untickedFacts: Set<CharacterFactIdentity> {+        Set(proposedFacts.filter { !$0.isTicked }.map(\.fact.identity))+    }+}++@MainActor @Observable+final class CharacterReviewModel: Identifiable {+    /// Presented with `sheet(item:)`, so the sheet comes and goes with the model+    /// rather than with a separate flag that can disagree with it.+    nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) }++    /// The rows still undecided. Decided rows leave immediately; a refused+    /// acceptance keeps its row, because there is still a decision owed.+    private(set) var rows: [CharacterReviewRow] = []+    /// What the last refusal was, in the reader's words. Nil when nothing has+    /// been refused (Req 2.7's disclosure).+    private(set) var disclosure: String?+    /// Whether the disclosure has somewhere for the reader to go — only a torn+    /// refusal does (Req 2.8), and it routes where every torn record is+    /// resolved.+    private(set) var routesToCheckLibrary = false++    var isEmpty: Bool { rows.isEmpty }++    private let workID: UUID+    private var characters: [WorkCharacterPresentation]+    /// Q88's display order needs the notes' capture order, and a *proposed* fact+    /// has no `WorkCharacterPresentation` to have been ordered inside. Without+    /// it a merged row's facts came out in entry-UUID order — the pipeline's+    /// canonical order (Q75) — and contradicted the work page showing the same+    /// facts once they were kept.+    private var captureOrder: [UUID: Int]+    private let library: any LibraryProviding+    /// Told which row was decided, so the coordinator can drop it from what it+    /// holds.+    private let onDecision: @MainActor (String) -> Void+    /// Q66: where a `.reRouted` refusal's payload goes. The coordinator+    /// re-points the held row at the character the commit resolved onto, and+    /// only then is `refresh` worth re-reading.+    private let onReRoute: @MainActor (String, UUID?) -> Void+    /// The coordinator's held rows, for the one refresh Req 2.7 allows —+    /// **after** it has reconciled them (Q110). Async because that reconcile is+    /// a library read: the stale disclosure claims the list below is up to date,+    /// and the invalidation pass is the only thing that can make it so.+    private let refresh: @MainActor () async -> [ExtractionProposal]+    private var isSubmitting = false++    init(+        workID: UUID,+        proposals: [ExtractionProposal],+        characters: [WorkCharacterPresentation],+        captureOrder: [UUID: Int] = [:],+        library: any LibraryProviding,+        onDecision: @escaping @MainActor (String) -> Void,+        onReRoute: @escaping @MainActor (String, UUID?) -> Void,+        refresh: @escaping @MainActor () async -> [ExtractionProposal]+    ) {+        self.workID = workID+        self.characters = characters+        self.captureOrder = captureOrder+        self.library = library+        self.onDecision = onDecision+        self.onReRoute = onReRoute+        self.refresh = refresh+        // Snapshot at open (Req 2.7).+        self.rows = proposals.map {+            Self.row(from: $0, characters: characters, captureOrder: captureOrder)+        }+    }++    // MARK: - Reader input++    func setTicked(_ isTicked: Bool, factID: String, in rowID: String) {+        guard let rowIndex = rows.firstIndex(where: { $0.id == rowID }),+              let factIndex = rows[rowIndex].proposedFacts.firstIndex(where: { $0.id == factID })+        else { return }+        rows[rowIndex].proposedFacts[factIndex].isTicked = isTicked+    }++    /// Q92: an alias is struck *before* deciding, and a struck one is neither+    /// installed by an accept nor suppressed by a skip.+    func setStruck(_ isStruck: Bool, alias: String, in rowID: String) {+        guard let rowIndex = rows.firstIndex(where: { $0.id == rowID }),+              let aliasIndex = rows[rowIndex].aliases.firstIndex(where: { $0.name == alias })+        else { return }+        rows[rowIndex].aliases[aliasIndex].isStruck = isStruck+    }++    /// Dismissing leaves undecided rows exactly where they are (Req 2.6): the+    /// coordinator still holds them, and the next open re-presents them.+    func dismiss() {+        disclosure = nil+        routesToCheckLibrary = false+    }++    // MARK: - Decisions (Req 2.2)++    func accept(_ rowID: String) async { await decide(rowID, action: .accept) }++    func skip(_ rowID: String) async { await decide(rowID, action: .skip) }++    private func decide(_ rowID: String, action: CharacterDecisionAction) async {+        guard !isSubmitting, let row = rows.first(where: { $0.id == rowID }) else { return }+        isSubmitting = true+        defer { isSubmitting = false }+        disclosure = nil+        routesToCheckLibrary = false++        let request = row.proposal.decisionRequest(+            workID: workID, action: action,+            struckAliases: row.struckAliases, untickedFacts: row.untickedFacts)++        let outcome: CharacterDecisionOutcome+        do {+            outcome = try await library.commitCharacterDecision(request)+        } catch {+            disclosure = "That could not be saved. Try again."+            CharacterExtractionLog.failure(+                "character decision failed — \(CharacterExtractionLog.describe(error))")+            return+        }++        switch outcome {+        case .committed:+            rows.removeAll { $0.id == rowID }+            onDecision(rowID)+        case .refused(let refusal):+            await handle(refusal, rowID: rowID, action: action)+        }+    }++    /// Req 2.7/2.8's disclosures, and the one in-place refresh the sheet does.+    private func handle(+        _ refusal: CharacterDecisionRefusal, rowID: String, action: CharacterDecisionAction+    ) async {+        switch refusal {+        case .staleSource:+            disclosure = "That note changed while this was waiting, so nothing was saved. "+                + "The list below is up to date."+            // Req 2.7/Q110: the refresh is what *makes* that sentence true. It+            // drives the coordinator's reconcile, which is the only thing that+            // drops the stale row; re-reading unreconciled held rows re-presented+            // the same row and earned the same refusal on the next Keep.+            await refreshRows()+        case .reRouted(let target):+            disclosure = "This turned out to belong to a character you already have, "+                + "so nothing was saved. It is shown against them now."+            // Q66: the refusal carries where the row actually resolves, and+            // applying it before the re-read is what makes the re-presentation+            // real. Without it the reader looped Keep → refuse → Keep.+            onReRoute(rowID, target)+            await refreshRows()+        case .torn(let characterID):+            disclosure = characterID == nil+                ? "This work exists in differing copies, so nothing can be added to it yet. "+                    + "Open Check Library to choose which copy to keep."+                : "That character exists in differing copies, so nothing can be added to them "+                    + "yet. Open Check Library to choose which copy to keep."+            routesToCheckLibrary = true+        case .workGone:+            disclosure = nil+            rows = []+            onDecision(rowID)+        }+        CharacterExtractionLog.note(+            "character \(action.rawValue) refused — \(String(describing: refusal))",+            level: .info)+    }++    /// The refreshed list, re-presented from what the coordinator now holds — so+    /// a candidate that has become a bundle is shown as one and the reader is+    /// not looped through the same refusal.+    ///+    /// **Both** halves are re-read (Q110). The coordinator reconciles before it+    /// answers, and the work's characters come back with the rows: the character+    /// a re-route resolved onto may have been created seconds ago by a sibling+    /// accept in this very sheet, and a bundle drawn against the open-time+    /// snapshot would be shown under the model's spelling with no existing facts+    /// beside it.+    private func refreshRows() async {+        let held = await refresh()+        if let detail = try? await library.workDetail(id: workID) {+            characters = detail.characters+            captureOrder = detail.captureOrder+        } else {+            // A failed read leaves the previous snapshot standing: an empty+            // character list would redraw every bundle as if its target held+            // nothing, which is a worse answer than a slightly old one.+            CharacterExtractionLog.failure(+                "character review refresh could not re-read the work's characters")+        }+        // Both kept per row, not per value: one row per name key per work+        // (Q83/Q100), and two rows proposing the same alias string — or the same+        // fact — are two decisions. Keyed globally, one row's strike travelled to+        // the other's on every refresh.+        //+        // Built with a uniquing rule rather than `uniqueKeysWithValues`: the+        // ledger's grain makes a repeated key impossible, and a view model is+        // not the place to make that guess fatal.+        let ticks = Dictionary(+            rows.map { row in+                (row.id, Dictionary(+                    row.proposedFacts.map { ($0.id, $0.isTicked) },+                    uniquingKeysWith: { first, _ in first }))+            },+            uniquingKeysWith: { first, _ in first })+        let strikes = Dictionary(+            rows.map { ($0.id, $0.struckAliases) }, uniquingKeysWith: { first, _ in first })+        rows = held.map { proposal in+            var row = Self.row(+                from: proposal, characters: characters, captureOrder: captureOrder)+            for index in row.proposedFacts.indices {+                // The reader's ticks survive the refresh: they decided those,+                // and the refusal was about the store, not about them.+                row.proposedFacts[index].isTicked =+                    ticks[row.id]?[row.proposedFacts[index].id] ?? true+            }+            for index in row.aliases.indices {+                row.aliases[index].isStruck =+                    strikes[row.id]?.contains(row.aliases[index].name) == true+            }+            return row+        }+    }++    // MARK: - Row construction++    private static func row(+        from proposal: ExtractionProposal, characters: [WorkCharacterPresentation],+        captureOrder: [UUID: Int]+    ) -> CharacterReviewRow {+        let targetID: UUID? = if case .existing(let id) = proposal.target { id } else { nil }+        let target = targetID.flatMap { id in characters.first { $0.id == id } }+        return CharacterReviewRow(+            id: proposal.nameKey,+            // A bundle is shown under the character's own name, not the model's+            // spelling of it: the reader knows them by what they called them.+            name: target?.name ?? proposal.name,+            isBundle: proposal.isBundle,+            targetID: targetID,+            aliases: proposal.proposedAliases.map {+                CharacterReviewAlias(name: $0, isStruck: false)+            },+            // Q88/AC 2.1's display order, applied here rather than upstream: the+            // pipeline's own order is Q75's canonical one (entries by UUID),+            // which is right for merging and encoding and wrong on screen. The+            // reader reads a character's facts as history, and the work page+            // shows exactly this order once they are kept.+            proposedFacts: proposal.facts+                .map { fact in+                    let stored = fact.storedFact+                    return CharacterReviewFact(+                        id: stored.displayRowID,+                        statement: fact.statement, quote: fact.quote, source: fact.source,+                        isTicked: true, fact: stored)+                }+                .sorted { left, right in+                    let leftTier = left.source.displayTier(captureOrder: captureOrder)+                    let rightTier = right.source.displayTier(captureOrder: captureOrder)+                    if leftTier != rightTier { return leftTier < rightTier }+                    if left.quote != right.quote { return left.quote < right.quote }+                    return left.statement < right.statement+                },+            existingFacts: target?.facts ?? [],+            proposal: proposal)+    }+}
Asterism/Asterism/ContentView.swift Modified +13 / -1
diff --git a/Asterism/Asterism/ContentView.swift b/Asterism/Asterism/ContentView.swiftindex 69c95cc..d47fd6d 100644--- a/Asterism/Asterism/ContentView.swift+++ b/Asterism/Asterism/ContentView.swift@@ -206,11 +206,17 @@ struct ContentView: View {         // foreground. On-open and on-request work is the reader's and continues.         .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification)) { _ in             model.suggestions?.resignActive()+            // `character-extraction` Req 1.2, the same rule: the sweep stops with+            // the foreground. A manual pass is the reader's and continues.+            model.characterExtraction?.resignActive()         }         // Req 5.6: held suggestions are cheap to recompute and are the first         // thing to go under memory pressure.         .onReceive(NotificationCenter.default.publisher(for: UIApplication.didReceiveMemoryWarningNotification)) { _ in             model.suggestions?.memoryWarning()+            // Held proposals cost a sweep to re-derive and nothing to lose:+            // their revisions are not covered until a decision commits (Q61).+            model.characterExtraction?.memoryWarning()         }     } @@ -302,7 +308,13 @@ struct ContentView: View {                                 // the destination is declared *here* rather                                 // than beside the work's own below.                                 onSelectEntry: { selectedWorkChapterEntryID = $0 },-                                exportModel: model.markdownExportModel(forWork: workID))+                                exportModel: model.markdownExportModel(forWork: workID),+                                // `character-extraction`: the indicator, the+                                // review sheet and the manual pass. Only this+                                // route gets it — the Merge sheet's embedded+                                // copy of this screen is a preview of a work,+                                // not a place to decide about it.+                                extraction: model.characterExtraction)                                 .navigationDestination(item: $selectedWorkChapterEntryID) {                                     entryID in                                     entryDetail(for: entryID)
Asterism/Asterism/PipelineLog.swift Added +78 / -0
diff --git a/Asterism/Asterism/PipelineLog.swift b/Asterism/Asterism/PipelineLog.swiftnew file mode 100644index 0000000..09b36fe--- /dev/null+++ b/Asterism/Asterism/PipelineLog.swift@@ -0,0 +1,78 @@+import AsterismIntelligence+import Foundation+import OSLog++/// The diagnostic-log body both on-device model pipelines use.+///+/// Rule suggestion and character extraction each have the same problem: every+/// failure settles silently, so a hostname that produces no suggestion and a+/// source that produced no characters are indistinguishable from work that was+/// never attempted. The log lines are the only place the reason survives, and+/// both pipelines draw the same privacy split — the *reason* (hostname, work id,+/// step, counts, durations, refusal causes) is always readable, the *content*+/// (capture titles, URLs, note text, evidence spans, proposed names) only in a+/// debug build.+///+/// The two façades below it stay, because a call site should name the pipeline+/// it belongs to and each carries its own Console category.+///+/// `nonisolated` because both pipelines run their model work off the main actor,+/// and every member under this target's default isolation would otherwise be+/// main-actor bound.+nonisolated struct PipelineLog {+    let logger: Logger++    init(category: String) {+        logger = Logger(subsystem: "me.nore.ig.Asterism", category: category)+    }++    /// The `#if` has to wrap the whole call rather than a constant: `privacy:`+    /// accepts nothing but a literal member of `OSLogPrivacy` — not a variable,+    /// and not a static of our own ("argument must be a static method or+    /// property of 'OSLogPrivacy'").+    ///+    /// Both arguments are autoclosures and neither is evaluated until the level+    /// is known to be enabled. Every call site interpolates — a proposal's+    /// fields, an error stringified, a duration — and `Logger`'s own laziness+    /// cannot help with that, because the interpolation happens at the call+    /// site, before the message is ever handed over.+    func note(+        _ reason: @autoclosure () -> String,+        content: @autoclosure () -> String? = nil,+        level: OSLogType = .default+    ) {+        guard logger.isEnabled(type: level) else { return }+        // Bound to locals first: `OSLogMessage`'s interpolation is an *escaping*+        // autoclosure, and a non-escaping parameter cannot be captured by one.+        let reason = reason()+        guard let content = content() else {+            logger.log(level: level, "\(reason, privacy: .public)")+            return+        }+        #if DEBUG+        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .public)")+        #else+        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .private)")+        #endif+    }++    /// A step that failed with an error rather than an answer.+    func failure(+        _ reason: @autoclosure () -> String, content: @autoclosure () -> String? = nil+    ) {+        note(reason(), content: content(), level: .error)+    }++    /// The one spelling of a failure these logs use, shared with the package so+    /// a model client's default `describe(_:)` reads the same.+    static func describe(_ error: any Error) -> String {+        SuggestionFailure.describe(error)+    }++    /// Durations read better in the log as a plain millisecond count than as+    /// `Duration`'s "0.123 seconds".+    static func milliseconds(_ duration: Duration) -> Int64 {+        let components = duration.components+        return components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000+    }+}
Asterism/Asterism/RuleSuggestion/RuleSuggester.swift Modified +5 / -25
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggester.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggester.swiftindex 5d97be8..2108af6 100644--- a/Asterism/Asterism/RuleSuggestion/RuleSuggester.swift+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggester.swift@@ -93,32 +93,12 @@ actor RuleSuggester: RuleSuggesting {          // Steps 3–6 under the timeout, measured from the model request.         let started = ContinuousClock.now-        let outcome = try await withThrowingTaskGroup(of: PipelineOutcome.self) { group in-            group.addTask {-                .settled(try await self.run(corpus: corpus, basis: basis, hostname: hostname))-            }-            group.addTask {-                try await Task.sleep(for: self.timeout)-                return .timedOut-            }-            guard let first = try await group.next() else { throw CancellationError() }-            // Whichever lost is cancelled here and awaited on scope exit, so a-            // timeout is thrown only once the work has actually stopped.-            group.cancelAll()-            return first-        }--        switch outcome {-        case .settled(let suggestion):-            return (suggestion, started.duration(to: .now))-        case .timedOut:-            throw AttemptTimeout(modelPhase: started.duration(to: .now))+        // The race itself is `withAttemptTimeout` — the same body+        // `CharacterExtractor` bounds its request with.+        let suggestion = try await withAttemptTimeout(self.timeout, startedAt: started) {+            try await self.run(corpus: corpus, basis: basis, hostname: hostname)         }-    }--    private nonisolated enum PipelineOutcome: Sendable {-        case settled(RuleSuggestion?)-        case timedOut+        return (suggestion, started.duration(to: .now))     }      // MARK: - Steps 3–6
Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift Modified +51 / -10
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swiftindex 6802407..18c3d06 100644--- a/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionCoordinator.swift@@ -31,6 +31,19 @@ struct SystemSuggestionEnvironment: SuggestionEnvironment {     var now: ContinuousClock.Instant { .now } } +extension SuggestionEnvironment {+    /// The three gates as a ledger reads them — one spelling, because both+    /// coordinators ask the same question of the same protocol and a+    /// disagreement between them would be a sweep gated differently from the+    /// other for no stated reason.+    var modelWorkEnvironment: ModelWorkEnvironment {+        ModelWorkEnvironment(+            isActive: isActive,+            isLowPowerMode: isLowPowerModeEnabled,+            thermalState: thermalState)+    }+}+ /// Owns everything a suggestion needs that is not the attempt itself: the /// ledger, the in-flight `Task`, the callers waiting on it, and the library /// read that produces a hostname's fingerprint.@@ -49,6 +62,10 @@ final class RuleSuggestionCoordinator {     private let model: any RuleSuggestionModelClient     private let suggester: any RuleSuggesting     private let environment: any SuggestionEnvironment+    /// The app-wide model lane (character-extraction Req 1.3, Q62). Rule+    /// suggestion's own ledger still arbitrates its three origins against each+    /// other; the lane arbitrates against the *other* feature's model work.+    private let lane: ModelLane      private var ledger = RuleSuggestionLedger()     /// The attempt is the coordinator's, never the caller's: cancelling whoever@@ -68,12 +85,14 @@ final class RuleSuggestionCoordinator {         library: any LibraryProviding,         model: any RuleSuggestionModelClient,         suggester: any RuleSuggesting,-        environment: any SuggestionEnvironment = SystemSuggestionEnvironment()+        environment: any SuggestionEnvironment = SystemSuggestionEnvironment(),+        lane: ModelLane = .shared     ) {         self.library = library         self.model = model         self.suggester = suggester         self.environment = environment+        self.lane = lane         self.isModelAvailable = model.availability().isAvailable     } @@ -296,7 +315,7 @@ final class RuleSuggestionCoordinator {                 await cancelInFlight()             case .start:                 RuleSuggestionLog.note("\(hostname): attempt start (\(origin.rawValue))")-                beginAttempt(hostname: hostname)+                beginAttempt(hostname: hostname, origin: origin)                 return await awaitAttempt()             }         }@@ -304,10 +323,31 @@ final class RuleSuggestionCoordinator {      // MARK: - The attempt Task -    private func beginAttempt(hostname: String) {-        attemptStartedAt = environment.now+    private func beginAttempt(hostname: String, origin: Origin) {+        // Deliberately not set until the lane is held: **a lane wait is not a+        // settlement**, so a claim cancelled while queued must charge nothing+        // (character-extraction Q68). `elapsedSinceStart()` reads zero until+        // `markAttemptStart()` runs.+        attemptStartedAt = nil         attemptTask = Task { [weak self] in             guard let self else { return }+            let token: LaneToken+            do {+                token = try await self.lane.acquire(ModelLaneClass(origin)) { [weak self] in+                    // The other feature's interactive work is asking this+                    // background attempt to yield: cancel and settle on the way+                    // out, which is the same two-step protocol this coordinator+                    // runs for its own pre-emptions.+                    Task { @MainActor in self?.attemptTask?.cancel() }+                }+            } catch {+                RuleSuggestionLog.note("\(hostname): lane claim withdrawn before the model call")+                self.settle(hostname: hostname, settlement: .cancelled, modelPhase: .zero)+                return+            }+            defer { token.release() }++            self.markAttemptStart()             do {                 let result = try await self.suggester.attempt(hostname: hostname)                 self.settle(@@ -326,6 +366,12 @@ final class RuleSuggestionCoordinator {         }     } +    /// The model phase starts when the lane is held, not when the attempt was+    /// asked for.+    private func markAttemptStart() {+        attemptStartedAt = environment.now+    }+     /// Only ever reached from the attempt's own `Task`, which is the only thing     /// that can end an attempt — so clearing the handles here cannot strand a     /// newer one: a new attempt starts only after this one has terminated.@@ -364,12 +410,7 @@ final class RuleSuggestionCoordinator {         return attemptStartedAt.duration(to: environment.now)     } -    private var currentEnvironment: RuleSuggestionEnvironment {-        RuleSuggestionEnvironment(-            isActive: environment.isActive,-            isLowPowerMode: environment.isLowPowerModeEnabled,-            thermalState: environment.thermalState)-    }+    private var currentEnvironment: ModelWorkEnvironment { environment.modelWorkEnvironment }      // MARK: - Candidate rows 
Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift Modified +11 / -38
diff --git a/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift b/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swiftindex b9577e8..07c398b 100644--- a/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift+++ b/Asterism/Asterism/RuleSuggestion/RuleSuggestionLog.swift@@ -1,4 +1,3 @@-import AsterismIntelligence import Foundation import OSLog @@ -14,66 +13,40 @@ import OSLog /// with /// `log show --predicate 'subsystem == "me.nore.ig.Asterism" AND category == "RuleSuggestion"' --last 10m`. ///+/// The body — the privacy split, the autoclosure laziness, the `describe` and+/// `milliseconds` spellings — is `PipelineLog`'s, shared with character+/// extraction. What lives here is the category and the name a call site reads.+/// /// `nonisolated` because the suggester runs on its own actor, off the main one /// (Req 5.1), and every static under this target's default isolation would /// otherwise be main-actor bound. nonisolated enum RuleSuggestionLog {-    static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "RuleSuggestion")+    static let pipeline = PipelineLog(category: "RuleSuggestion")++    static var logger: Logger { pipeline.logger }      /// `reason` — hostname, step, origin, error type, count, duration — is     /// always readable. `content` — capture titles, URLs, the text the model     /// copied out of them — is readable only in a debug build; a release build     /// leaves it to the logging system to redact.-    ///-    /// The `#if` has to wrap the whole call rather than a constant: `privacy:`-    /// accepts nothing but a literal member of `OSLogPrivacy` — not a variable,-    /// and not a static of our own ("argument must be a static method or-    /// property of 'OSLogPrivacy'").-    ///-    /// Both arguments are autoclosures and neither is evaluated until the level-    /// is known to be enabled. Every call site here interpolates — a proposal's-    /// four fields, a capture title looked back up in the basis, an error-    /// stringified — and `Logger`'s own laziness cannot help with that, because-    /// the interpolation happens at the call site, before the message is ever-    /// handed over.     static func note(         _ reason: @autoclosure () -> String,         content: @autoclosure () -> String? = nil,         level: OSLogType = .default     ) {-        guard logger.isEnabled(type: level) else { return }-        // Both are bound to locals first: `OSLogMessage`'s interpolation is an-        // *escaping* autoclosure, and a non-escaping parameter cannot be-        // captured by one.-        let reason = reason()-        guard let content = content() else {-            logger.log(level: level, "\(reason, privacy: .public)")-            return-        }-        #if DEBUG-        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .public)")-        #else-        logger.log(level: level, "\(reason, privacy: .public) | \(content, privacy: .private)")-        #endif+        pipeline.note(reason(), content: content(), level: level)     }      /// A step that failed with an error rather than an answer.     static func failure(         _ reason: @autoclosure () -> String, content: @autoclosure () -> String? = nil     ) {-        note(reason(), content: content(), level: .error)+        pipeline.failure(reason(), content: content())     } -    /// The one spelling of a failure this pipeline's logs use, shared with the-    /// package so a model client's default `describe(_:)` reads the same.-    static func describe(_ error: any Error) -> String {-        SuggestionFailure.describe(error)-    }+    static func describe(_ error: any Error) -> String { PipelineLog.describe(error) } -    /// Durations read better in the log as a plain millisecond count than as-    /// `Duration`'s "0.123 seconds".     static func milliseconds(_ duration: Duration) -> Int64 {-        let components = duration.components-        return components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000+        PipelineLog.milliseconds(duration)     } }
Asterism/Asterism/UITestLaunchSupport.swift Modified +62 / -0
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex f859624..1ce2a67 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -40,6 +40,11 @@ enum UITestFixtureKind: Equatable {     /// reachable from a launch — nothing in the app can preserve a capture, only     /// the share extension can.     case pendingCaptures+    /// One work with notes that name people, so the extraction journey has+    /// something to ground against (`character-extraction`). The scripted client+    /// supplies the model's answer; the grounding, the assembly and every+    /// decision are the real ones.+    case characters     /// A wholly legal library whose captures span 31 calendar months     /// (`seedSpanningMonthsFixture`). The Stats page's All-time graph draws one     /// band per month and can outgrow its viewport; no other fixture here spans@@ -83,6 +88,8 @@ enum UITestLaunchSupport {     /// needs no second table. Matched after the exact `seeded-scale-m4`.     static let seededScaleM4ToleratedPrefix = "seeded-scale-m4-"     static let seededComposedScenario = "seeded-composed"+    /// The one work the character-extraction journey runs over.+    static let seededCharactersScenario = "seeded-characters"     /// The 31-month library the Stats All-time graph needs to be drawn at more     /// than one band.     static let seededSpanningMonthsScenario = "seeded-spanning-months"@@ -129,6 +136,59 @@ enum UITestLaunchSupport {     }     #endif +    /// `character-extraction`: which scripted extraction client this launch runs+    /// with. Absent means the on-device model, which is what production always+    /// uses — and what no UI test may use (design, Testing Strategy: the UI+    /// suites are stub-driven, so nothing there depends on the host having a+    /// model or on the model repeating itself, Req 1.7).+    static let extractionKey = "ASTERISM_UI_TEST_EXTRACTION"++    #if DEBUG || ASTERISM_PERFORMANCE_TESTING+    /// The characters the canned client proposes. They ground against the+    /// `seeded-characters` fixture's generic notes, so what the reader sees is+    /// the real grounding and assembly over a scripted model answer rather than+    /// a hand-built proposal list.+    ///+    /// Inside the guard with its only consumer: a release build has no scripted+    /// client to hand it to, and a fixture compiled into the shipping binary is+    /// dead weight that reads as production data.+    static let cannedExtractionResult = ExtractionResult(characters: [+        ExtractedCharacter(+            name: "Ada/Nightjar",+            facts: [+                ExtractedFact(+                    statement: "Ada keeps the lighthouse.",+                    quote: "Ada keeps the lighthouse"),+                ExtractedFact(+                    statement: "Ada is called Nightjar by the crew.",+                    quote: "the crew call her Nightjar"),+            ]),+        ExtractedCharacter(+            name: "Brede",+            facts: [+                ExtractedFact(+                    statement: "Brede rows the tender.",+                    quote: "Brede rows the tender")+            ]),+    ])++    static func characterExtractionClient(+        environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment()+    ) -> (any CharacterExtractionModelClient)? {+        switch environmentProvider.environment[extractionKey] {+        case "canned":+            return StubCharacterExtractionModelClient(result: cannedExtractionResult)+        case "empty":+            return StubCharacterExtractionModelClient(result: ExtractionResult(characters: []))+        case "unavailable":+            return StubCharacterExtractionModelClient(+                availability: .unavailable(reason: "UI test"))+        default:+            return nil+        }+    }+    #endif+     static func request(         environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment(),         temporaryDirectory: URL = FileManager.default.temporaryDirectory@@ -147,6 +207,8 @@ enum UITestLaunchSupport {             fixture = .scaleM4         case seededComposedScenario:             fixture = .composed+        case seededCharactersScenario:+            fixture = .characters         case seededSpanningMonthsScenario:             fixture = .spanningMonths         case seededPendingCapturesScenario:
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +91 / -3
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 3da6b15..73d82e4 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -124,8 +124,8 @@ public final class AppLibraryModel {     /// The resolved configuration after successful bootstrap.     private var resolvedConfiguration: LibraryConfiguration?     private var repository: (any LibraryProviding)?-    /// Retains the concrete repository for backup export (conforms to BackupV5SnapshotProviding).-    private var backupRepository: (any BackupV5SnapshotProviding)?+    /// Retains the concrete repository for backup export (conforms to BackupV6SnapshotProviding).+    private var backupRepository: (any BackupV6SnapshotProviding)?     /// A pre-bootstrap failure used to fail closed on invalid debug launch input.     private let startupFailureMessage: String?     /// Seeds only a fresh, explicit temporary configuration used by UI tests.@@ -137,6 +137,11 @@ public final class AppLibraryModel {     /// in the test seams that publish a repository without one.     private(set) var suggestions: RuleSuggestionCoordinator? +    /// `character-extraction`: the run's held extraction proposals and the+    /// passes that produce them. Nil until the library is ready, for the same+    /// reason `suggestions` is.+    private(set) var characterExtraction: CharacterExtractionCoordinator?+     /// Production initializer: resolves the configuration from the App Group     /// identifier its bundle declares. Fails closed (→ unavailable) when App     /// Group resolution fails.@@ -301,6 +306,7 @@ public final class AppLibraryModel {             // repository that just opened. Nothing is attempted until an             // activation sweeps or an editor opens.             self.suggestions = Self.makeSuggestionCoordinator(library: repo)+            self.characterExtraction = Self.makeCharacterExtractionCoordinator(library: repo)             interruptedImport = await repo.interruptedImport()             // Before `refreshAll()`, so a capture this pass commits is in the             // first snapshots rather than waiting for the next activation, and@@ -318,6 +324,15 @@ public final class AppLibraryModel {             startSyncObservation(                 configuration: configuration, mirroring: await repo.mirroring)             scheduleLaunchReconcile()+            // `character-extraction` Req 1.1: a cold launch *is* the app becoming+            // active, but `didBecomeActive` fires while this is still opening —+            // `handleActivation()` sees a library that is not ready yet and+            // returns. Without this the first launch of a run sweeps nothing and+            // the reader waits for a second foregrounding. Never awaited, and a+            // later activation queues behind it rather than racing it.+            if let characterExtraction {+                Task { await characterExtraction.activationSweep() }+            }             Self.logger.debug("Library bootstrap completed")         } catch {             state = .unavailable(message: String(describing: error))@@ -374,6 +389,14 @@ public final class AppLibraryModel {         setAsideCaptureRows = []         drainReport = nil         drainReportNotice = nil+        // The run's model coordinators go with the repository they were built+        // over. `ContentView`'s resign-active and memory-warning handlers reach+        // them through this model whether or not a library is open, and a+        // coordinator left standing would drive candidate reads and coverage+        // writes against a store that has been shut down. Cleared before the+        // guard below, so a teardown with nothing open still forgets them.+        suggestions = nil+        characterExtraction = nil         guard let repository else { return }         await repository.shutdown()         self.repository = nil@@ -395,6 +418,12 @@ public final class AppLibraryModel {         if let suggestions {             Task { await suggestions.activationSweep() }         }+        // `character-extraction` Req 1.2, the same rule and the same reason. The+        // two sweeps queue against each other in the shared `ModelLane` rather+        // than here, so neither has to know the other exists.+        if let characterExtraction {+            Task { await characterExtraction.activationSweep() }+        }     }      /// The model client the run's suggestions are computed with. A UI test may@@ -411,6 +440,20 @@ public final class AppLibraryModel {             suggester: RuleSuggester(library: library, model: client))     } +    /// The model client the run's extraction passes are computed with. A UI test+    /// may substitute a scripted one; production always asks the on-device+    /// model. The lane is deliberately not a parameter: `ModelLane.shared` is+    /// the whole guarantee that one request runs at a time app-wide (Req 1.3).+    private static func makeCharacterExtractionCoordinator(+        library: any LibraryProviding+    ) -> CharacterExtractionCoordinator {+        var client: any CharacterExtractionModelClient = FoundationCharacterExtractionModelClient()+        #if DEBUG || ASTERISM_PERFORMANCE_TESTING+        if let scripted = UITestLaunchSupport.characterExtractionClient() { client = scripted }+        #endif+        return CharacterExtractionCoordinator(library: library, model: client)+    }+     // MARK: - Preserved captures (pending-capture-queue)      /// Builds the queue over the certified library, restores a report the reader@@ -603,6 +646,9 @@ public final class AppLibraryModel {         // `rule-suggestion` Req 5.5's single invalidation hook: every library         // mutation the app performs funnels through here (Q43).         await suggestions?.reconcile()+        // `character-extraction` Req 2.8's, and the same hook: a deleted work, a+        // deleted character and an edited note all arrive here.+        await characterExtraction?.reconcile()         dropSettledConflicts()     } @@ -1333,7 +1379,7 @@ public final class AppLibraryModel {         guard let repo = backupRepository, let config = resolvedConfiguration else { return nil }         let stagingDir = config.rootDirectory             .appending(path: "Library/Caches/BackupExports")-        let exporter = BackupV5Exporter(+        let exporter = BackupV6Exporter(             repository: repo,             stagingDirectory: stagingDir         )@@ -1446,6 +1492,43 @@ public final class AppLibraryModel {         }     } +    /// `character-extraction`: one work whose notes name people, so the+    /// extraction journey has something real to ground against.+    ///+    /// The text is chosen to make the scripted answer+    /// (`UITestLaunchSupport.cannedExtractionResult`) ground: every quote+    /// appears verbatim, and both halves of the slash-compound name appear, so+    /// the split produces a proposed alias the reader can strike (Decision 5,+    /// Q92). Nothing about the *proposals* is seeded — grounding, assembly,+    /// matching and every decision are the shipping ones.+    private func seedCharactersFixture(in repo: LibraryRepository) async throws {+        let entry = try await repo.capture(CaptureDraft(+            captureTitle: "Chapter 1 - The Lamp Room",+            captureTitleSource: .host,+            rawURLString: "https://characters.test/lamp/1",+            note: "Brede rows the tender out at dusk."))++        let work = try await repo.createWork(+            NewWorkDraft(displayTitle: "The Lamp Room", hostname: "characters.test"))+        let assignment = try await repo.entry(id: entry.id)+        _ = try await repo.moveEntry(+            entry.id,+            basis: EntryAssignmentBasis(entry: assignment),+            to: .existing(work.id))++        let reloaded = try await repo.work(id: work.id)+        _ = try await repo.updateWork(+            id: work.id,+            basis: WorkEditBasis(work: reloaded),+            draft: WorkMetadataDraft(+                displayTitle: reloaded.displayTitle,+                typeAssignment: reloaded.typeDisplay.assignment,+                genreTags: reloaded.genreTags,+                genericNotes: "Ada keeps the lighthouse, and the crew call her Nightjar. "+                    + "Brede rows the tender out at dusk."))+        Self.logger.debug("Seeded the character-extraction UI test fixture")+    }+     /// Preserves two captures in the disposable root's spool, exactly as the     /// share extension would have: one the drain commits, and one it can never     /// commit and therefore sets aside (Req 6.4).@@ -1534,6 +1617,11 @@ public final class AppLibraryModel {             #endif         } +        if fixture == .characters {+            try await seedCharactersFixture(in: repository)+            return+        }+         if fixture == .composed {             // Production opens through the app-role opener, so the composed             // fixture seeds through the ordinary bootstrap, which creates and
Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift Modified +9 / -0
diff --git a/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift b/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swiftindex 066cba5..f902347 100644--- a/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift+++ b/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift@@ -75,6 +75,15 @@ public final class DuplicateResolutionModel {         return []     } +    /// `character-extraction` Req 6.5: a torn character discloses and resolves+    /// through this sheet, exactly as a torn Work or Entry does — chosen-only,+    /// with no union (the `.work` arm's write shape, not the `.entry` arm's+    /// note-append).+    public var characterVariants: [CharacterVariantChoice] {+        if case .character(_, let variants, _, _) = contract { return variants }+        return []+    }+     public var differingFields: [DuplicateResolutionField] { contract?.differingFields ?? [] }      public var canConfirm: Bool {
Asterism/Asterism/ViewModels/EntryDetailModel.swift Modified +12 / -0
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex 223372d..b67baa0 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -124,6 +124,18 @@ public final class EntryDetailModel {      public var supportsTitleRecovery: Bool { capabilities.supportsSegmentTeaching } +    /// `character-extraction` Req 5.4: the characters holding a fact that cites+    /// this entry, in name order. Read off the teaching detail rather than+    /// fetched separately — it was populated in the same locked context as+    /// everything else on this screen, and a second read would describe a+    /// different moment.+    ///+    /// Empty is the ordinary case, and what makes the section absent rather than+    /// empty.+    public var citingCharacters: [EntryCitingCharacter] {+        teachingDetail?.citingCharacters ?? []+    }+     // MARK: - What the title card says (Q51)      /// The heading: the parsed chapter, falling back to the presentation title
Asterism/Asterism/ViewModels/MaintenanceViewModels.swift Modified +13 / -2
diff --git a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swiftindex b647197..7b81dab 100644--- a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift+++ b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift@@ -277,8 +277,19 @@ public final class LibraryDiagnosticsModel {     /// Req 9.3's rows for a duplicate set: what it is, how many records, and     /// the route named rather than "no merge exists".     private static func row(_ item: DuplicateReviewItem) -> Row {-        let noun = item.recordType == .work ? "work" : "entry"-        let plural = item.recordType == .work ? "works" : "entries"+        // Three record types now, not two (`character-extraction` Req 6.5): the+        // work-else-entry ternary this replaces called a torn character an+        // entry, which sends the reader looking for a note that does not exist.+        let noun: String+        let plural: String+        switch item.recordType {+        case .work: (noun, plural) = ("work", "works")+        case .character: (noun, plural) = ("character", "characters")+        // The rule types never reach a review item — they collapse silently —+        // but the switch has to be total, and "entry" is what the ternary this+        // replaces already said about them.+        case .entry, .titleRule, .urlRule: (noun, plural) = ("entry", "entries")+        }         let problem: String         switch item.route {         case .sheet where item.isTorn:
Asterism/Asterism/ViewModels/SettingsBackupModel.swift Modified +17 / -11
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex 78f7e78..86ac442 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -5,19 +5,20 @@ import OSLog // MARK: - Backup Exporting Protocol  /// Test seam abstracting the exporter's operations needed by the Settings-/// surface. Conforms `BackupV5Exporter` to this protocol via extension below.+/// surface. Conforms `BackupV6Exporter` to this protocol via extension below. ///-/// Settings exports 5/6 (`configurable-work-types` Req 7.6): the archive has to-/// carry the configured type list, which the 4/4 format has no place for. The-/// 4/4 exporter is still declared — it is the format the app still *imports* —-/// but nothing in the app writes one any more.+/// Settings exports 6/7 (`character-extraction` Req 6.1): the archive has to+/// carry characters, their suppressions and their coverage, which the 5/6+/// format has no place for. The older exporters are still declared — those are+/// formats the app still *imports* — but nothing in the app writes one any+/// more. public protocol BackupExporting: Sendable {-    func export(metadata: BackupV5Metadata) async throws -> BackupExportResult+    func export(metadata: BackupV6Metadata) async throws -> BackupExportResult     func cleanup(_ result: BackupExportResult)     func scavengeStaleFiles() } -extension BackupV5Exporter: BackupExporting {}+extension BackupV6Exporter: BackupExporting {}  // MARK: - Settings Backup View Model @@ -78,7 +79,7 @@ public final class SettingsBackupModel {         currentResult = nil          do {-            let metadata = BackupV5Metadata(+            let metadata = BackupV6Metadata(                 appBuild: Self.currentAppBuild(),                 exportedAt: Date()             )@@ -91,7 +92,7 @@ public final class SettingsBackupModel {             state = .failed             // Privacy-safe: log only the error category, never user content             errorMessage = Self.privacySafeMessage(for: error)-            if let exportError = error as? BackupV5ExportError,+            if let exportError = error as? BackupV6ExportError,                case .tornGroups = exportError {                 routesToCheckLibrary = true             }@@ -136,7 +137,7 @@ public final class SettingsBackupModel {             "Backup export failed due to an encoding error. Please try again."         case is BackupValidationError:             "Backup validation failed. The export was not saved. Please try again."-        case let error as BackupV5ExportError:+        case let error as BackupV6ExportError:             exportMessage(for: error)         default:             "Backup export failed. Please try again."@@ -155,7 +156,12 @@ public final class SettingsBackupModel {     /// reader to retry while something settled; no such state can block an     /// export, because a torn group is divergent by definition and a divergent     /// set never resolves without the reader.-    private static func exportMessage(for error: BackupV5ExportError) -> String {+    ///+    /// The `tornGroups` arm now also covers a torn **character** group+    /// (`character-extraction` Req 6.5, Q105). It needs no new sentence: the+    /// payload carries a count and a route, not a record kind, and Check Library+    /// is where every torn group is resolved.+    private static func exportMessage(for error: BackupV6ExportError) -> String {         switch error {         case .tornGroups(let payload):             tornGroupsMessage(payload)
Asterism/Asterism/ViewModels/WorkDetailModel.swift Modified +299 / -11
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex d58405a..800b49a 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -30,6 +30,45 @@ public final class WorkDetailModel {     /// Req 5.4, newest-first with cleaned display titles.     public var chapterRows: [WorkChapterRow] { presentation?.chapterRows ?? [] } +    // MARK: - Characters (`character-extraction` Reqs 3.2, 3.7, 5.1–5.3)++    /// The work's characters as the repository ordered them, with their facts in+    /// Q88's display order. Empty is the ordinary case, and what makes the+    /// section absent rather than empty (Req 5.1).+    public var characters: [WorkCharacterPresentation] { presentation?.characters ?? [] }++    /// Q88's capture order, for the surfaces that order facts the repository has+    /// not already ordered — the review sheet's proposed rows.+    public var captureOrder: [UUID: Int] { presentation?.captureOrder ?? [:] }++    /// The reader's intended state for each character, keyed by id.+    ///+    /// Captured at `load()` — **before** any `save()` reloads the screen (the+    /// task list's own note): a draft taken after the reload would be taken from+    /// the values the reload published, so an edit made before the metadata save+    /// would vanish without a trace.+    public private(set) var characterDrafts: [UUID: CharacterDraft] = [:]+    /// What each draft is compared against, and the basis the commit re-verifies+    /// (Q73).+    private var characterBases: [UUID: CharacterEditBasis] = [:]+    /// The order the reader performed things in (Q97). A combine followed by an+    /// edit of the target must reach the repository in that order.+    private var stagedCharacterOperations: [StagedCharacterOperation] = []+    /// Whether the last refused character step has somewhere for the reader to+    /// go — only a torn refusal does (Req 6.5).+    public private(set) var characterRefusalRoutesToCheckLibrary = false++    /// A staged operation, before the drafts it describes are read at commit.+    ///+    /// Creates and combines are recorded; updates and deletes are derived from+    /// the drafts at commit, because an edit made three times is still one+    /// update and recording each keystroke would send three.+    private enum StagedCharacterOperation: Equatable {+        case create(id: UUID)+        case delete(id: UUID)+        case combine(source: UUID, target: UUID)+    }+     /// Req 2.8, mirrored from Entry detail: a torn Work's authored fields are     /// read-only until its resolution. The repository refuses the write either     /// way (`updateWork` returns `.conflict(.torn)`), so this is not the@@ -107,6 +146,7 @@ public final class WorkDetailModel {             typeOptions = await pickerOptions(carrying: snapshot.typeDisplay)             draftTags = snapshot.genreTags             draftNotes = snapshot.genericNotes+            adoptCharacterDrafts(detail.characters)             state = .ready             await loadWorkURL()         } catch {@@ -299,6 +339,10 @@ public final class WorkDetailModel {     /// a draft like any other, so a cancel puts back what the last projection     /// gave the field.     public func cancelEditing() {+        // The mode goes first: the X *is* the reader's discard, and+        // `adoptCharacterDrafts` refuses to replace the drafts of a session that+        // is still open.+        isEditing = false         restoreDraftsFromSnapshot()         draftWorkURL = baselineWorkURL         errorMessage = nil@@ -306,7 +350,6 @@ public final class WorkDetailModel {         // explanation of why this Work has no suggestion is not a refusal, and         // stays.         refreshWorkURLStatusFromCandidate()-        isEditing = false     }      /// The editor's confirmation: saves where a draft differs, then leaves.@@ -326,15 +369,29 @@ public final class WorkDetailModel {             guard await commitWorkURLDraft() else { return }             committedURL = true         }-        if hasUnsavedChanges {-            await save()+        // Derived once. It JSON-encodes every character's facts to decide what+        // differs, and the state it reads cannot move between here and the+        // commit: `adoptCharacterDrafts` refuses to touch a staged session, and+        // the metadata save below skips its reload when a character step+        // follows.+        let characterOperations = stagedCharacterEdits()+        let hasCharacterChanges = !characterOperations.isEmpty+        let hadMetadataChanges = hasUnsavedChanges++        if hadMetadataChanges {+            // One reload, not two: the character step is the last write, so when+            // one follows, its reload is the one that publishes everything.+            await save(reloading: !hasCharacterChanges)             guard errorMessage == nil else { return }-        } else if committedURL {-            // The URL commit re-projects the field but not the presentation, and-            // view mode's link glyph reads the presentation.-            await load()         }+        guard await commitCharacterStep(characterOperations) else { return }++        // Dropped before the reload, so the re-adopt below is allowed to replace+        // the drafts with what the store now holds.         isEditing = false+        if hasCharacterChanges || (committedURL && !hadMetadataChanges) {+            await load()+        }     }      private func restoreDraftsFromSnapshot() {@@ -343,10 +400,234 @@ public final class WorkDetailModel {         draftAssignment = work.typeDisplay.assignment         draftTags = work.genreTags         draftNotes = work.genericNotes+        adoptCharacterDrafts(presentation?.characters ?? [])+    }++    // MARK: - The character edit session (Reqs 3.2, 3.7, 5.3)++    /// Whether the reader has a character edit session in progress — staged+    /// creates, deletes or combines (Q97), or a draft that no longer matches the+    /// basis it was taken from.+    ///+    /// **This is where the "do not clobber the drafts" invariant lives.** It+    /// used to live at the one call site that could trip it, as a+    /// capture-save-restore around the metadata save; stated here it holds for+    /// every reload, including ones added later.+    private var hasStagedCharacterSession: Bool {+        guard isEditing else { return false }+        if !stagedCharacterOperations.isEmpty { return true }+        return characterDrafts.contains { id, draft in+            guard let basis = characterBases[id] else { return false }+            return Self.differs(draft, from: basis)+        }+    }++    private func adoptCharacterDrafts(_ characters: [WorkCharacterPresentation]) {+        // A reload in the middle of an open session would replace the reader's+        // drafts with the stored values and drop the staged operations — the+        // edits this session exists to commit, gone without a trace. The reader+        // discards through the X, which leaves edit mode first.+        guard !hasStagedCharacterSession else { return }+        characterDrafts = Dictionary(+            uniqueKeysWithValues: characters.map { character in+                (character.id,+                 CharacterDraft(+                    name: character.name, note: character.note, aliases: character.aliases,+                    facts: character.facts.map(\.fact)))+            })+        characterBases = Dictionary(+            uniqueKeysWithValues: characters.map { ($0.id, $0.editBasis) })+        stagedCharacterOperations = []+        characterRefusalRoutesToCheckLibrary = false+    }++    public func characterDraft(for id: UUID) -> CharacterDraft? { characterDrafts[id] }++    /// The characters this session created, in the order the reader added them.+    ///+    /// Derived from the staged operations rather than from the draft dictionary:+    /// a dictionary has no order, and the screen sorting its keys by+    /// `uuidString` dropped each new row into a random place among the others.+    public var createdCharacterIDs: [UUID] {+        stagedCharacterOperations.compactMap { staged in+            guard case .create(let id) = staged, characterDrafts[id] != nil else { return nil }+            return id+        }+    }++    /// The seam the review sheet commits its decisions through — this screen's+    /// own, so the sheet and the page cannot end up on two repositories.+    /// Decisions commit independently of edit mode (Q37), which is why the sheet+    /// holds the library rather than routing through this model.+    public var libraryForReview: any LibraryProviding { library }++    /// Req 5.3's read-only gate, per character: a torn character is read-only+    /// until its resolution, and so is every character while the *work* is torn.+    public func canEditCharacter(id: UUID) -> Bool {+        guard !isReadOnly else { return false }+        return characters.first { $0.id == id }?.isTorn == false+    }++    /// Who this character may be combined into (Req 3.7): every other editable+    /// character of the work. A torn character refuses on either side, so it is+    /// offered on neither.+    public func combineTargets(for id: UUID) -> [WorkCharacterPresentation] {+        guard canEditCharacter(id: id) else { return [] }+        return characters.filter { $0.id != id && canEditCharacter(id: $0.id) }+    }++    public func updateCharacterDraft(id: UUID, _ edit: (inout CharacterDraft) -> Void) {+        guard var draft = characterDrafts[id] else { return }+        edit(&draft)+        characterDrafts[id] = draft+    }++    /// Deleting a fact is expressed by its absence from the draft (Q50): the+    /// commit suppresses the triple of every fact the basis had and the draft+    /// does not.+    public func deleteFact(_ identity: CharacterFactIdentity, from id: UUID) {+        updateCharacterDraft(id: id) { draft in+            draft.facts.removeAll { $0.identity == identity }+        }+    }++    @discardableResult+    public func addCharacter(named name: String) -> UUID {+        // A local id for the session only. The row's real UUID is minted by the+        // repository at commit, along with its retained key (Q46).+        let id = UUID()+        characterDrafts[id] = CharacterDraft(name: name)+        stagedCharacterOperations.append(.create(id: id))+        return id+    }++    public func deleteCharacter(id: UUID) {+        characterDrafts[id] = nil+        if let index = stagedCharacterOperations.firstIndex(of: .create(id: id)) {+            // Created and deleted in one session: neither ever existed, and+            // sending both would ask the repository to suppress a key it just+            // minted.+            stagedCharacterOperations.remove(at: index)+            return+        }+        stagedCharacterOperations.append(.delete(id: id))+    }++    /// Q97: staged in the edit session and discardable by its X until commit.+    /// The source leaves the page as soon as it is staged, so the reader sees+    /// what they asked for before it lands.+    public func combineCharacter(source: UUID, into target: UUID) {+        guard canEditCharacter(id: source), canEditCharacter(id: target) else { return }+        characterDrafts[source] = nil+        stagedCharacterOperations.append(.combine(source: source, target: target))+    }++    /// The session's operations, in the order the reader performed them.+    ///+    /// Updates and deletes of characters that already existed are derived here+    /// rather than recorded as they happen: an edit made three times is one+    /// update, and the draft is the intended state either way. Creates and+    /// combines carry their own position, because their order against each+    /// other is what Q97 is about.+    private func stagedCharacterEdits() -> [CharacterEditOperation] {+        var operations: [CharacterEditOperation] = []+        var handled: Set<UUID> = []+        for staged in stagedCharacterOperations {+            switch staged {+            case .create(let id):+                guard let draft = characterDrafts[id] else { continue }+                operations.append(.create(draft))+                handled.insert(id)+            case .delete(let id):+                guard let basis = characterBases[id] else { continue }+                operations.append(.delete(basis: basis))+                handled.insert(id)+            case .combine(let source, let target):+                guard let sourceBasis = characterBases[source],+                      let targetBasis = characterBases[target]+                else { continue }+                operations.append(.combine(source: sourceBasis, target: targetBasis))+                handled.insert(source)+            }+        }+        for (id, draft) in characterDrafts.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {+            guard !handled.contains(id), let basis = characterBases[id] else { continue }+            guard Self.differs(draft, from: basis) else { continue }+            operations.append(.update(basis: basis, draft: draft))+        }+        return operations+    }++    /// A draft differs from its basis when the basis it *would* produce differs.+    /// Comparing through `CharacterEditBasis` rather than field by field is what+    /// makes a re-ordered fact list not a change: the basis canonicalises the+    /// facts and sorts the aliases (Q75), and the repository compares the same+    /// way.+    private static func differs(_ draft: CharacterDraft, from basis: CharacterEditBasis) -> Bool {+        CharacterEditBasis(+            characterID: basis.characterID, name: draft.name, note: draft.note,+            aliases: draft.aliases, facts: draft.facts) != basis+    }++    /// The one repository call the session's character changes go through.+    /// Returns false where the step refused, which keeps the editor open.+    private func commitCharacterStep(_ operations: [CharacterEditOperation]) async -> Bool {+        guard !operations.isEmpty else { return true }+        characterRefusalRoutesToCheckLibrary = false+        do {+            let outcome = try await library.commitCharacterEdits(+                workID: workID, operations: operations)+            switch outcome {+            case .committed:+                stagedCharacterOperations = []+                return true+            case .refused(let refusal):+                errorMessage = Self.characterRefusalMessage(refusal)+                if case .torn = refusal { characterRefusalRoutesToCheckLibrary = true }+                if case .workTorn = refusal { characterRefusalRoutesToCheckLibrary = true }+                state = .error(message: errorMessage ?? "")+                return false+            }+        } catch {+            errorMessage = error.localizedDescription+            state = .error(message: error.localizedDescription)+            Self.logger.error(+                "Character edit step failed: \(String(describing: error), privacy: .public)")+            return false+        }+    }++    /// Q73: the whole step refuses, and the message names the character so the+    /// reader knows which of their edits is the one in question.+    private static func characterRefusalMessage(_ refusal: CharacterEditRefusal) -> String {+        switch refusal {+        case .basisMismatch(_, let name):+            "\(name) changed elsewhere while you were editing, so nothing was saved. "+                + "Leave and come back to see the current version."+        case .torn(_, let name):+            "\(name) exists in differing copies, so nothing was saved. "+                + "Open Check Library to choose which copy to keep."+        case .workTorn:+            // Q104: a tear can sync in while the editor sits open, so the commit+            // re-checks the work. Without this arm the reader would be told+            // nothing about a refusal they can act on.+            "This work now exists in differing copies, so nothing was saved. "+                + "Open Check Library to choose which copy to keep."+        case .characterGone:+            "That character was deleted elsewhere, so nothing was saved."+        case .workGone:+            "This work was deleted elsewhere, so nothing was saved."+        }     }      /// Commits metadata edit. Suppresses duplicate submissions.-    public func save() async {+    public func save() async { await save(reloading: true) }++    /// - Parameter reloading: whether a successful write re-reads the screen.+    ///   False only inside `commitEditing()`, where a character step follows and+    ///   its own reload is the one that publishes both halves — two full+    ///   `workDetail` reads for one confirmation is one too many.+    private func save(reloading: Bool) async {         guard !isSubmitting else { return }         isSubmitting = true         state = .submitting@@ -375,11 +656,18 @@ public final class WorkDetailModel {                 return             }             await onMutation()-            await load()+            if reloading {+                await load()+            } else {+                state = .ready+            }         } catch {             errorMessage = error.localizedDescription-            // No optimistic mutation: restore from prior snapshot.-            restoreDraftsFromSnapshot()+            // The drafts stay, exactly as on the `.conflict` path above: nothing+            // was written, the editor is still open, and the draft is the only+            // copy of itself until it lands. Restoring from the snapshot here+            // re-adopted the stored characters and so silently threw away the+            // session's staged character operations as well as the typed fields.             state = .error(message: error.localizedDescription)             Self.logger.error("Work update failed: \(String(describing: error), privacy: .public)")         }
Asterism/Asterism/Views/CharacterReviewView.swift Added +174 / -0
diff --git a/Asterism/Asterism/Views/CharacterReviewView.swift b/Asterism/Asterism/Views/CharacterReviewView.swiftnew file mode 100644index 0000000..d1264c4--- /dev/null+++ b/Asterism/Asterism/Views/CharacterReviewView.swift@@ -0,0 +1,174 @@+import AsterismCore+import ConstellationKit+import SwiftUI++/// The review list (Reqs 2.1, 2.2, 2.5, 2.7).+///+/// One section per candidate or bundle, each with its own Keep and Skip. The+/// list is a snapshot taken when the sheet opened, so nothing moves under the+/// reader while they decide.+struct CharacterReviewView: View {+    @Bindable var model: CharacterReviewModel+    let onDone: () -> Void+    /// Where a torn refusal sends the reader (Req 2.8).+    let onCheckLibrary: () -> Void++    var body: some View {+        NavigationStack {+            Group {+                if model.isEmpty {+                    completeContent+                } else {+                    reviewContent+                }+            }+            .navigationTitle("Suggested characters")+            .toolbar {+                // Every row commits on its own, so this button commits nothing —+                // it leaves the list, and undecided rows stay for a later visit+                // (Req 2.6). That is a close, and it renders as the platform's X.+                ToolbarItem(placement: .cancellationAction) {+                    Button(role: .close) {+                        model.dismiss()+                        onDone()+                    }+                    .frame(+                        minWidth: AsterismLayout.minHitTarget,+                        minHeight: AsterismLayout.minHitTarget)+                    .accessibilityIdentifier("character-review-done")+                    .accessibilityLabel("Done")+                }+            }+        }+    }++    private var reviewContent: some View {+        Form {+            Section {+                Label(+                    "Suggested from your notes — review before keeping",+                    systemImage: "sparkles")+                    .font(.footnote)+                    .foregroundStyle(AsterismColors.amberText)+                    .accessibilityElement(children: .combine)+                    .accessibilityIdentifier("character-review-banner")+            }++            if let disclosure = model.disclosure {+                Section {+                    Text(disclosure)+                        .font(.footnote)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityIdentifier("character-review-disclosure")+                    if model.routesToCheckLibrary {+                        Button("Open Check Library", action: onCheckLibrary)+                            .frame(minHeight: AsterismLayout.minHitTarget)+                            .accessibilityIdentifier("character-review-check-library")+                    }+                }+            }++            ForEach(model.rows) { row in+                Section(header: ConstellationSectionHeader(row.name, accent: .cyan)) {+                    rowContent(row)+                }+            }+        }+    }++    @ViewBuilder+    private func rowContent(_ row: CharacterReviewRow) -> some View {+        if row.isBundle {+            Text("Adds to a character you already have.")+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("character-review-bundle-note")+        }++        ForEach(row.aliases) { alias in+            // Q92: shown and strikeable before accepting, never installed+            // silently. "A/B" in these notes is as often a pairing as a second+            // name for one person, and a wrong alias would mis-route for ever.+            Toggle(isOn: aliasBinding(row: row, alias: alias)) {+                Text("Also known as \(alias.name)")+                    .strikethrough(alias.isStruck)+                    .foregroundStyle(alias.isStruck ? AnyShapeStyle(.secondary)+                                                    : AnyShapeStyle(.primary))+            }+            .frame(minHeight: AsterismLayout.minHitTarget)+            .accessibilityIdentifier("character-review-alias-\(alias.name)")+        }++        ForEach(row.proposedFacts) { fact in+            Toggle(isOn: factBinding(row: row, fact: fact)) {+                VStack(alignment: .leading, spacing: 4) {+                    Text(fact.statement)+                    Text("“\(fact.quote)”")+                        .font(.caption)+                        .foregroundStyle(.secondary)+                }+            }+            .frame(minHeight: AsterismLayout.minHitTarget)+            .accessibilityIdentifier("character-review-fact-\(fact.id)")+        }++        if row.proposedFacts.isEmpty, row.aliases.isEmpty {+            Text("A name, with nothing said about them yet.")+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("character-review-name-only")+        }++        // Req 2.1: a bundle is shown *beside* what the character already holds,+        // so the reader can see what is being added to rather than guessing.+        if !row.existingFacts.isEmpty {+            DisclosureGroup("Already kept (\(row.existingFacts.count))") {+                ForEach(row.existingFacts) { fact in+                    Text(fact.statement)+                        .font(.caption)+                        .foregroundStyle(.secondary)+                }+            }+            .accessibilityIdentifier("character-review-existing-\(row.id)")+        }++        HStack(spacing: 12) {+            Button("Keep") {+                Task { await model.accept(row.id) }+            }+            .buttonStyle(.constellationPrimary)+            .accessibilityIdentifier("character-review-keep-\(row.id)")++            Button("Skip") {+                Task { await model.skip(row.id) }+            }+            .buttonStyle(.constellationSecondary)+            .accessibilityIdentifier("character-review-skip-\(row.id)")+        }+        .frame(maxWidth: .infinity)+    }++    private var completeContent: some View {+        ContentUnavailableView(+            "Nothing left to review",+            systemImage: "checkmark.circle",+            description: Text("You have decided every suggestion for this work."))+            .accessibilityIdentifier("character-review-complete")+    }++    private func aliasBinding(+        row: CharacterReviewRow, alias: CharacterReviewAlias+    ) -> Binding<Bool> {+        Binding(+            get: { !alias.isStruck },+            set: { model.setStruck(!$0, alias: alias.name, in: row.id) })+    }++    private func factBinding(+        row: CharacterReviewRow, fact: CharacterReviewFact+    ) -> Binding<Bool> {+        Binding(+            get: { fact.isTicked },+            set: { model.setTicked($0, factID: fact.id, in: row.id) })+    }+}
Asterism/Asterism/Views/DuplicateResolutionView.swift Modified +38 / -0
diff --git a/Asterism/Asterism/Views/DuplicateResolutionView.swift b/Asterism/Asterism/Views/DuplicateResolutionView.swiftindex fa8e5c0..002edfa 100644--- a/Asterism/Asterism/Views/DuplicateResolutionView.swift+++ b/Asterism/Asterism/Views/DuplicateResolutionView.swift@@ -89,6 +89,9 @@ struct DuplicateResolutionView: View {                 ForEach(model.workVariants) { variant in                     variantRow(id: variant.id) { workVariantContent(variant) }                 }+                ForEach(model.characterVariants) { variant in+                    variantRow(id: variant.id) { characterVariantContent(variant) }+                }             } header: {                 // Decision 1 and Q46: duplicate review is an                 // actionable-attention surface, and this header is the sheet's@@ -165,6 +168,41 @@ struct DuplicateResolutionView: View {         .accessibilityAddTraits(model.selectedVariantID == id ? [.isSelected] : [])     } +    /// `character-extraction` Req 6.5. The three things that tell one copy of a+    /// character from another: what they are called, what the reader wrote about+    /// them, and how much of it there is. The facts themselves are not listed —+    /// a divergent character can hold dozens, and the choice is between copies,+    /// not between facts.+    @ViewBuilder+    private func characterVariantContent(_ variant: CharacterVariantChoice) -> some View {+        VStack(alignment: .leading, spacing: 4) {+            Text(variant.name.isEmpty ? "(no name)" : variant.name)+                .font(.body)+                .accessibilityIdentifier("duplicate-variant-character-name")+            if !variant.note.isEmpty {+                Text(variant.note)+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("duplicate-variant-character-note")+            }+            HStack(spacing: 8) {+                Text(Pluralisation.count(variant.factCount, "fact", "facts"))+                    .font(.caption)+                    .foregroundStyle(.secondary)+                    .accessibilityIdentifier("duplicate-variant-character-facts")+                if !variant.aliases.isEmpty {+                    Text("also \(variant.aliases.joined(separator: ", "))")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.cyan)+                        .accessibilityIdentifier("duplicate-variant-character-aliases")+                }+            }+            Text(variant.firstCapturedAt, style: .date)+                .font(.caption2)+                .foregroundStyle(.tertiary)+        }+    }+     @ViewBuilder     private func entryVariantContent(_ variant: EntryVariantChoice) -> some View {         VStack(alignment: .leading, spacing: 4) {
Asterism/Asterism/Views/EntryDetailView.swift Modified +31 / -0
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex 122c49d..ef4242f 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -143,6 +143,8 @@ struct EntryDetailView: View {                 .accessibilityIdentifier("entry-detail-delete-button")             } +            citingCharactersSection+             // Q49: last, and collapsed. The capture's site and the             // title-recovery actions — neither is something a reader needs             // while writing a note.@@ -316,6 +318,35 @@ struct EntryDetailView: View {         }     } +    /// `character-extraction` Req 5.4: who this note is about, where any+    /// character says so. Absent rather than empty — most notes name nobody the+    /// reader has kept, and an empty section on every entry would be noise on+    /// the screen where they write.+    ///+    /// A count rather than a list of statements: a character can cite one entry+    /// several times, and saying "3 facts" is more honest than the name three+    /// times. The facts themselves live on the work page, which is where they+    /// are edited.+    @ViewBuilder+    private var citingCharactersSection: some View {+        if !model.citingCharacters.isEmpty {+            Section {+                ForEach(model.citingCharacters) { character in+                    LabeledContent(character.name) {+                        Text(Pluralisation.count(character.factCount, "fact", "facts"))+                            .font(.caption)+                            .foregroundStyle(.secondary)+                    }+                    .accessibilityIdentifier("entry-detail-citing-character")+                    .accessibilityLabel(+                        "\(character.name), \(Pluralisation.count(character.factCount, "fact", "facts")) citing this note")+                }+            } header: {+                ConstellationSectionHeader("Characters", accent: .violet)+            }+        }+    }+     /// Q49's bottom disclosure: where the capture came from and what can be done     /// about a title that came out wrong — one collapsed group at the end of the     /// screen instead of two sections above the note.
Asterism/Asterism/Views/RecentView.swift Modified +8 / -0
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex a776aa5..b89a3d1 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -698,6 +698,14 @@ struct RecentDuplicatePlan: Equatable {     }      private static func text(for item: DuplicateReviewItem) -> String {+        // `character-extraction` Req 6.5: a torn character reaches this filter+        // like any other non-Entry set, and the work-shaped sentences below+        // would call it a work. It is resolved in the same sheet, so only the+        // noun changes.+        if item.recordType == .character {+            return "A character arrived more than once and the copies differ. "+                + "Choose which one to keep."+        }         switch item.route {         case .merge:             return "\(Pluralisation.count(item.memberIDs.count, "work is", "works are")) "
Asterism/Asterism/Views/WorkDetailView.swift Modified +413 / -1
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex 8be350d..5ffd72e 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -42,19 +42,30 @@ struct WorkDetailView: View {     /// route, and a sheet is its own glass layer (§4) — so the host says which     /// it is rather than the screen assuming.     let showsSky: Bool+    /// `character-extraction`: the run's extraction state, or nil where the host+    /// has none — the Merge route's embedded copy of this screen, and every+    /// preview. Nil hides the indicator and the manual trigger entirely.+    let extraction: CharacterExtractionCoordinator?+    /// The review sheet, `@State`-owned so its snapshot survives a re-render+    /// (Req 2.7).+    @State private var reviewModel: CharacterReviewModel?+    /// Which character the combine picker is open for.+    @State private var combineSource: UUID?      init(         model: WorkDetailModel,         onResolveDuplicate: (() -> Void)? = nil,         onSelectEntry: ((UUID) -> Void)? = nil,         exportModel: MarkdownExportModel? = nil,-        showsSky: Bool = true+        showsSky: Bool = true,+        extraction: CharacterExtractionCoordinator? = nil     ) {         _model = State(initialValue: model)         _exportModel = State(initialValue: exportModel)         self.onResolveDuplicate = onResolveDuplicate         self.onSelectEntry = onSelectEntry         self.showsSky = showsSky+        self.extraction = extraction     }      var body: some View {@@ -89,6 +100,23 @@ struct WorkDetailView: View {         .onChange(of: model.isDeleted) { _, deleted in             if deleted { dismiss() }         }+        // Req 2.1's review list. Presented from the indicator, and it snapshots+        // its proposals at open (Req 2.7) — so a sweep settling behind it+        // changes nothing under the reader.+        .sheet(item: $reviewModel) { review in+            CharacterReviewView(+                model: review,+                onDone: {+                    reviewModel = nil+                    // A decision writes characters and coverage, so the page has+                    // to re-read to show what was kept.+                    Task { await model.load() }+                },+                onCheckLibrary: {+                    reviewModel = nil+                    onResolveDuplicate?()+                })+        }     }      // MARK: - The §6 layout@@ -97,10 +125,13 @@ struct WorkDetailView: View {     private func workContent(_ work: WorkSnapshot) -> some View {         List {             if model.isReadOnly { duplicateReviewSection }+            tornCharactersSection+            proposalsIndicatorSection              if model.isEditing {                 editHeaderSection                 editNotesSection+                editCharactersSection                 workURLSection                 urlIdentitySection                 manageSection@@ -109,6 +140,7 @@ struct WorkDetailView: View {                 pulseSection                 openLastNotedSection                 viewNotesSection(work)+                charactersSection                 chapterSection             } @@ -630,6 +662,386 @@ struct WorkDetailView: View {         }     } +    // MARK: - Characters (`character-extraction` Reqs 2.1, 5.1–5.3, 1.11)++    /// Req 6.5: torn characters are disclosed the way torn works are — in the+    /// same top slot, wearing the same amber border, routing to the same sheet.+    /// A torn character is otherwise only visible as a label deep in the+    /// characters section, which is not a disclosure the reader will meet.+    @ViewBuilder+    private var tornCharactersSection: some View {+        let torn = model.characters.filter(\.isTorn)+        if !torn.isEmpty {+            Section {+                VStack(alignment: .leading, spacing: 8) {+                    Text(torn.count == 1+                        ? "\(torn[0].name) arrived more than once and the copies differ."+                        : "\(torn.count) characters arrived more than once and their copies differ.")+                        .font(.callout)+                        .foregroundStyle(AsterismColors.primaryText)+                        .accessibilityIdentifier("work-detail-torn-characters-notice")+                    Text("Editing them is off until you choose which copy to keep — nothing has been lost.")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.secondaryText)+                    if let onResolveDuplicate {+                        Button("Resolve copies") { onResolveDuplicate() }+                            .buttonStyle(.constellationSecondary)+                            .accessibilityIdentifier("work-detail-resolve-character-button")+                    }+                }+                .frame(maxWidth: .infinity, alignment: .leading)+                .padding(12)+                .constellationCard(borderColor: AsterismColors.attentionBorder)+                .constellationListRow()+            }+        }+    }++    /// Req 2.1's indicator, in the top slot the torn-work card already owns and+    /// wearing the same amber border: held proposals are the other thing on this+    /// screen that is waiting for a decision.+    ///+    /// Absent when nothing is held — a work with no characters and no proposals+    /// shows nothing character-related at all (Req 5.1, Q107's one exception+    /// being the manual-pass trigger).+    ///+    /// Absent in edit mode too (Q109), which is the gating `offersManualPass`+    /// already had: the sheet's completion reload rebuilds the character drafts,+    /// and that would silently destroy the staged session Q97 promises is+    /// discardable only by the reader's own X.+    @ViewBuilder+    private var proposalsIndicatorSection: some View {+        if let extraction, let work = model.work, !model.isEditing,+           extraction.hasProposals(for: work.id) {+            Section {+                VStack(alignment: .leading, spacing: 8) {+                    Label(+                        "\(Pluralisation.count(extraction.held(for: work.id).count, "character suggestion", "character suggestions")) from your notes",+                        systemImage: "sparkles")+                        .font(.callout)+                        .foregroundStyle(AsterismColors.primaryText)+                        // On the label, not on the card: a container's+                        // identifier is inherited by every descendant and would+                        // mask the button below it+                        // (`docs/agent-notes/testing.md`).+                        .accessibilityIdentifier("work-detail-character-proposals")+                    Text("Nothing is kept until you say so.")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.secondaryText)+                    Button("Review suggestions") { openReview(workID: work.id) }+                        .buttonStyle(.constellationSecondary)+                        .accessibilityIdentifier("work-detail-review-characters-button")+                }+                .frame(maxWidth: .infinity, alignment: .leading)+                .padding(12)+                .constellationCard(borderColor: AsterismColors.attentionBorder)+                .constellationListRow()+            }+        }+    }++    /// Req 5.1/5.2's view-mode section, in the `chapterSection` idiom: absent+    /// when the work has no characters, and its facts already in Q88's display+    /// order — the repository's, not this view's.+    @ViewBuilder+    private var charactersSection: some View {+        if !model.characters.isEmpty || offersManualPass {+            Section {+                ForEach(model.characters) { character in+                    characterRow(character)+                }+                manualPassRow+            } header: {+                ConstellationSectionHeader("Characters", accent: .violet)+            }+        }+    }++    @ViewBuilder+    private func characterRow(_ character: WorkCharacterPresentation) -> some View {+        VStack(alignment: .leading, spacing: 6) {+            HStack(spacing: 8) {+                Text(character.name)+                    .font(AsterismTypography.serifHeading)+                    // On the name, not on the row: the row holds the fact rows+                    // and their citation buttons, and a container identifier+                    // masks every one of them.+                    .accessibilityIdentifier("work-detail-character")+                if character.isTorn {+                    // Req 6.5: disclosed where the reader meets it, and resolved+                    // where every torn record is.+                    Label("Differing copies", systemImage: "exclamationmark.circle")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityIdentifier("work-detail-character-torn")+                }+            }+            if !character.aliases.isEmpty {+                Text("Also \(character.aliases.joined(separator: ", "))")+                    .font(.caption)+                    .foregroundStyle(AsterismColors.cyan)+            }+            if !character.note.isEmpty {+                Text(character.note)+                    .font(.footnote)+                    .foregroundStyle(AsterismColors.secondaryText)+            }+            ForEach(character.facts) { fact in+                characterFactRow(fact)+            }+        }+        .frame(maxWidth: .infinity, alignment: .leading)+        .constellationListRow()+    }++    /// Req 5.2: each citation opens the note it came from, where that note still+    /// exists. A dangling citation displays without navigation and is not an+    /// error (Req 3.5) — the statement and the quote are still the reader's.+    @ViewBuilder+    private func characterFactRow(_ fact: WorkCharacterFactRow) -> some View {+        VStack(alignment: .leading, spacing: 2) {+            Text(fact.statement)+                .font(.footnote)+            if let entryID = fact.citedEntryID, let onSelectEntry {+                Button(fact.citationTitle ?? "Open the note") { onSelectEntry(entryID) }+                    .font(.caption)+                    .buttonStyle(.plain)+                    .foregroundStyle(AsterismColors.cyan)+                    .accessibilityIdentifier("work-detail-character-citation")+            } else if fact.isDangling {+                Text("From a note that is no longer here")+                    .font(.caption2)+                    .foregroundStyle(.tertiary)+                    .accessibilityIdentifier("work-detail-character-citation-dangling")+            } else {+                Text("From this work's notes")+                    .font(.caption2)+                    .foregroundStyle(.tertiary)+            }+        }+        .accessibilityIdentifier("work-detail-character-fact")+    }++    /// Req 1.11's trigger and its outcome, in the `suggestRow` shape. Hidden —+    /// not disabled — while the model is away: an action that can never do+    /// anything is not an action.+    private var offersManualPass: Bool {+        guard let extraction, model.work != nil, !model.isEditing else { return false }+        return extraction.canRunManualPass+    }++    @ViewBuilder+    private var manualPassRow: some View {+        if offersManualPass, let extraction, let work = model.work {+            // One source of truth for "a pass is running": the coordinator sets+            // `.running` before it does anything and replaces it on the way out,+            // so a second `@State` flag beside it could only ever disagree.+            let outcome = extraction.manualOutcome(for: work.id)+            VStack(alignment: .leading, spacing: 8) {+                Button {+                    Task { await runManualPass(workID: work.id) }+                } label: {+                    Label("Look for characters", systemImage: "sparkles")+                        .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)+                }+                .buttonStyle(.constellationSecondary)+                .disabled(outcome == .running)+                .accessibilityIdentifier("work-detail-extract-characters")+                .overlay(alignment: .trailing) {+                    if outcome == .running {+                        ProgressView()+                            .controlSize(.small)+                            .padding(.trailing, 18)+                            .accessibilityIdentifier("work-detail-extract-busy")+                            .accessibilityLabel("Looking for characters")+                    }+                }++                // Req 1.11: a pass always ends with something visible. "No+                // proposals available" covers empty, failed and refused alike —+                // the causes are diagnostics only (Req 1.8).+                switch outcome {+                case .proposals(let count):+                    Label(+                        "\(Pluralisation.count(count, "suggestion", "suggestions")) ready",+                        systemImage: "sparkles")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityIdentifier("work-detail-extract-ready")+                case .noProposals:+                    Label("No suggestions available", systemImage: "exclamationmark.circle")+                        .font(.caption)+                        .foregroundStyle(AsterismColors.amberText)+                        .accessibilityIdentifier("work-detail-extract-none")+                case .running, .none:+                    EmptyView()+                }+            }+            .frame(maxWidth: .infinity, alignment: .leading)+            .constellationListRow()+            // Deliberately unidentified: an identifier here would be inherited+            // by the trigger and the outcome label, and both are addressed by+            // name (`docs/agent-notes/testing.md`).+        }+    }++    /// Req 5.3: creating, editing, deleting and combining live in the existing+    /// edit mode and follow its commit/discard semantics — including the torn+    /// read-only gate, which is per character as well as per work.+    @ViewBuilder+    private var editCharactersSection: some View {+        Section {+            ForEach(model.characters) { character in+                if let draft = model.characterDraft(for: character.id) {+                    editCharacterRow(character, id: character.id, draft: draft)+                }+            }+            ForEach(newCharacterIDs, id: \.self) { id in+                if let draft = model.characterDraft(for: id) {+                    editCharacterRow(nil, id: id, draft: draft)+                }+            }+            Button("Add a character") { _ = model.addCharacter(named: "") }+                .disabled(model.isReadOnly)+                .frame(minHeight: AsterismLayout.minHitTarget)+                .accessibilityIdentifier("work-detail-add-character")+        } header: {+            ConstellationSectionHeader("Characters", accent: .violet)+        }+    }++    /// The characters this session created, in the order the reader added them.+    ///+    /// The model's order, not a sort of the draft dictionary's keys: a UUID is+    /// random, so sorting by `uuidString` dropped each new row into an arbitrary+    /// place in the list and re-ordered the ones already there.+    private var newCharacterIDs: [UUID] { model.createdCharacterIDs }++    /// `characterID` is a parameter rather than something derived here: the id is+    /// what every binding, delete and combine on this row addresses, and the old+    /// `character?.id ?? id ?? UUID()` would have silently minted a fresh UUID —+    /// a row whose every control wrote to a draft that does not exist.+    @ViewBuilder+    private func editCharacterRow(+        _ character: WorkCharacterPresentation?, id characterID: UUID, draft: CharacterDraft+    ) -> some View {+        let editable = character.map { model.canEditCharacter(id: $0.id) } ?? !model.isReadOnly+        VStack(alignment: .leading, spacing: 8) {+            TextField("Name", text: characterBinding(characterID, \.name))+                .disabled(!editable)+                .accessibilityIdentifier("work-detail-character-name-field")+            TextField("Note", text: characterBinding(characterID, \.note), axis: .vertical)+                .disabled(!editable)+                .accessibilityIdentifier("work-detail-character-note-field")++            ForEach(draft.facts, id: \.identity) { fact in+                HStack(alignment: .top, spacing: 8) {+                    Text(fact.statement)+                        .font(.footnote)+                    Spacer(minLength: 0)+                    Button("Delete", role: .destructive) {+                        model.deleteFact(fact.identity, from: characterID)+                    }+                    .font(.caption)+                    .disabled(!editable)+                    .accessibilityIdentifier("work-detail-character-fact-delete")+                }+            }++            if character?.isTorn == true {+                Text("This character exists in differing copies — editing is off until you choose which one to keep.")+                    .font(.caption)+                    .foregroundStyle(AsterismColors.amberText)+                    .accessibilityIdentifier("work-detail-character-torn-notice")+            }++            HStack(spacing: 12) {+                if let character, !model.combineTargets(for: character.id).isEmpty {+                    Button("Combine into…") { combineSource = character.id }+                        .font(.caption)+                        .accessibilityIdentifier("work-detail-character-combine")+                }+                Button("Delete character", role: .destructive) {+                    model.deleteCharacter(id: characterID)+                }+                .font(.caption)+                .disabled(!editable)+                .accessibilityIdentifier("work-detail-character-delete")+            }+        }+        .constellationListRow()+        .accessibilityIdentifier("work-detail-character-editor")+        .confirmationDialog(+            "Combine into", isPresented: combinePresented(for: characterID),+            titleVisibility: .visible+        ) {+            if let character {+                ForEach(model.combineTargets(for: character.id)) { target in+                    Button(target.name) {+                        model.combineCharacter(source: character.id, into: target.id)+                        combineSource = nil+                    }+                }+            }+            Button("Cancel", role: .cancel) { combineSource = nil }+        } message: {+            Text("Their facts and names move across. Nothing is written until you tap the checkmark.")+        }+    }++    private func combinePresented(for id: UUID) -> Binding<Bool> {+        Binding(+            get: { combineSource == id },+            set: { if !$0 { combineSource = nil } })+    }++    private func characterBinding(+        _ id: UUID, _ keyPath: WritableKeyPath<CharacterDraft, String>+    ) -> Binding<String> {+        Binding(+            get: { model.characterDraft(for: id)?[keyPath: keyPath] ?? "" },+            set: { value in model.updateCharacterDraft(id: id) { $0[keyPath: keyPath] = value } })+    }++    private func openReview(workID: UUID) {+        guard let extraction else { return }+        // Q109/Req 1.11: opening the sheet is one of the two moments a stale+        // manual-pass outcome stops being about anything the reader is looking+        // at. Clearing it here is what keeps "3 suggestions ready" from standing+        // under the trigger for the rest of the app run.+        extraction.clearManualOutcome(for: workID)+        reviewModel = CharacterReviewModel(+            workID: workID,+            proposals: extraction.held(for: workID),+            characters: model.characters,+            captureOrder: model.captureOrder,+            library: model.libraryForReview,+            onDecision: { nameKey in extraction.discard(nameKey: nameKey, for: workID) },+            // Q66: a `.reRouted` refusal names the character the row really+            // resolves onto, and the coordinator is where the held row lives.+            onReRoute: { nameKey, target in+                extraction.retarget(nameKey: nameKey, for: workID, to: target)+            },+            // Q110: reconcile before answering. The refusal disclosure promises+            // the list below is up to date, and this is the pass that makes it+            // so — nothing else on this screen reaches the coordinator's+            // invalidation.+            refresh: {+                await extraction.reconcile()+                return extraction.held(for: workID)+            })+    }++    private func runManualPass(workID: UUID) async {+        guard let extraction else { return }+        // The previous pass's label goes before this one starts, so a stale+        // "no suggestions available" never sits under a running pass. The+        // coordinator sets `.running` immediately and finishes with the real+        // outcome, which is what drives both the spinner and the label.+        extraction.clearManualOutcome(for: workID)+        await extraction.runManualPass(workID: workID)+    }+     // MARK: - Torn Work notice      /// Req 2.8 and Req 9.2 on this screen, mirroring Entry detail: say plainly
Asterism/AsterismTests/AppLibraryModelTests.swift Modified +33 / -0
diff --git a/Asterism/AsterismTests/AppLibraryModelTests.swift b/Asterism/AsterismTests/AppLibraryModelTests.swiftindex 67af506..9cea7b0 100644--- a/Asterism/AsterismTests/AppLibraryModelTests.swift+++ b/Asterism/AsterismTests/AppLibraryModelTests.swift@@ -42,6 +42,39 @@ struct AppLibraryModelTests {         try? FileManager.default.removeItem(at: root)     } +    /// The two model coordinators are built over the repository that just+    /// opened, and `ContentView` reaches them through this model on every+    /// resign-active and memory warning — whether or not a library is open. A+    /// teardown that leaves them standing hands those handlers a coordinator+    /// whose library reads and coverage writes go to a store that has been shut+    /// down.+    @Test("Tearing the repository down forgets the run's model coordinators")+    @MainActor func teardownForgetsTheCoordinators() async throws {+        let root = FileManager.default.temporaryDirectory+            .appending(path: "asterism-teardown-coordinators-\(UUID())")+        let config = LibraryConfiguration(rootDirectory: root)+        defer { try? FileManager.default.removeItem(at: root) }+        let model = AppLibraryModel(configuration: config)++        await model.bootstrap()+        #expect(model.state == .ready)+        #expect(model.characterExtraction != nil)+        #expect(model.suggestions != nil)++        // A second open that cannot succeed: the store's directory is now a+        // file. `bootstrap()` tears down first, so what survives the failure is+        // exactly what the teardown forgot to clear.+        let storeDirectory = config.storeURL.deletingLastPathComponent()+        try? FileManager.default.removeItem(at: storeDirectory)+        FileManager.default.createFile(atPath: storeDirectory.path, contents: Data())++        await model.retry()++        #expect(model.state != .ready, "the second open failed, as the fixture arranged")+        #expect(model.characterExtraction == nil)+        #expect(model.suggestions == nil)+    }+     // MARK: - Req 4.4: an import that stopped partway is reported      @Test("A sidecar left by an interrupted import is read at bootstrap and named to the reader")
Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift Added +696 / -0
diff --git a/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift b/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swiftnew file mode 100644index 0000000..66f23d8--- /dev/null+++ b/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift@@ -0,0 +1,696 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// Tests for `CharacterExtractionCoordinator`: the sweep's bounds and gates, the+/// produced-none coverage write, and the manual pass.+///+/// The ledger's own transitions are covered host-side in+/// `CharacterExtractionLedgerTests`, and grounding and assembly in their own+/// suites. What is pinned here is what the coordinator does *around* them — the+/// library reads, the `Task`, the lane, the coverage write, and the outcome the+/// reader is shown.+///+/// Every case drives the **stub** model client. No live model call happens in+/// this suite, so nothing here depends on the host having a model.+@Suite("CharacterExtractionCoordinator")+@MainActor+struct CharacterExtractionCoordinatorTests {++    // MARK: - Doubles++    @MainActor+    final class StubEnvironment: SuggestionEnvironment {+        var isActive = true+        var isLowPowerModeEnabled = false+        var thermalState: ProcessInfo.ThermalState = .nominal+        var elapsed: Duration = .zero+        private let base = ContinuousClock.now+        var now: ContinuousClock.Instant { base.advanced(by: elapsed) }+    }++    /// A client whose availability can change between activations, which is the+    /// point of re-reading it. The package's stub is a value type, so the copy+    /// the coordinator holds could never change.+    final class MutableAvailabilityClient: CharacterExtractionModelClient, @unchecked Sendable {+        nonisolated(unsafe) var availabilityResult: ModelAvailability+        nonisolated(unsafe) var inner: StubCharacterExtractionModelClient+        /// Run **and awaited** at the end of every `extract`, so a test can move+        /// the coordinator's state at a known point mid-pass — between one+        /// source of a work and the next — rather than hoping a detached task+        /// lands in the right gap.+        nonisolated(unsafe) var onExtract: (@Sendable (ExtractionSource) async -> Void)?++        init(_ availability: ModelAvailability,+             inner: StubCharacterExtractionModelClient = StubCharacterExtractionModelClient()) {+            self.availabilityResult = availability+            self.inner = inner+        }++        func availability() -> ModelAvailability { availabilityResult }++        func extract(_ source: ExtractionSource) async throws -> ExtractionResult {+            let result = try await inner.extract(source)+            await onExtract?(source)+            return result+        }++        func isContextWindowOverflow(_ error: any Error) -> Bool {+            inner.isContextWindowOverflow(error)+        }+    }++    // MARK: - Fixtures++    /// One work, one generic-notes source, nothing covered and nothing known.+    private nonisolated static func candidate(+        workID: UUID = UUID(),+        title: String = "A Work",+        recency: TimeInterval = 0,+        notes: String = "Ada carried the lantern.",+        covered: Bool = false,+        characters: [CharacterMatchTarget] = [],+        acceptedFacts: Set<CharacterFactIdentity> = [],+        suppressions: CharacterSuppressionIndex = .empty+    ) -> CharacterExtractionCandidate {+        let fingerprint = CharacterCoverageFingerprint.of(notes)+        return CharacterExtractionCandidate(+            workID: workID,+            displayTitle: title,+            recency: Date(timeIntervalSince1970: recency),+            sources: [+                CharacterExtractionSource(+                    ref: .genericNotes, text: notes, fingerprint: fingerprint,+                    coveredFingerprint: covered ? fingerprint : nil)+            ],+            characters: characters,+            acceptedFactIdentities: acceptedFacts,+            suppressions: suppressions)+    }++    /// One work with **two** uncovered sources, so a stop taken between them is+    /// observable at all: the per-work guard cannot see it.+    private nonisolated static func twoSourceCandidate(+        workID: UUID, entryID: UUID, recency: TimeInterval = 0+    ) -> CharacterExtractionCandidate {+        let notes = "Ada carried the lantern."+        let chapter = "Brede followed."+        return CharacterExtractionCandidate(+            workID: workID,+            displayTitle: "A Work",+            recency: Date(timeIntervalSince1970: recency),+            sources: [+                CharacterExtractionSource(+                    ref: .genericNotes, text: notes,+                    fingerprint: CharacterCoverageFingerprint.of(notes),+                    coveredFingerprint: nil),+                CharacterExtractionSource(+                    ref: .entry(entryID), text: chapter,+                    fingerprint: CharacterCoverageFingerprint.of(chapter),+                    coveredFingerprint: nil),+            ],+            characters: [],+            acceptedFactIdentities: [],+            suppressions: .empty)+    }++    private nonisolated static func result(+        _ name: String = "Ada", statement: String = "Ada carried a lantern.",+        quote: String = "Ada carried the lantern"+    ) -> ExtractionResult {+        ExtractionResult(characters: [+            ExtractedCharacter(+                name: name, facts: [ExtractedFact(statement: statement, quote: quote)])+        ])+    }++    private func makeSUT(+        candidates: [CharacterExtractionCandidate] = [],+        availability: ModelAvailability = .available,+        stub: StubCharacterExtractionModelClient = StubCharacterExtractionModelClient(+            result: CharacterExtractionCoordinatorTests.result())+    ) -> (CharacterExtractionCoordinator, MockLibraryProvider, MutableAvailabilityClient,+          StubEnvironment) {+        let mock = MockLibraryProvider()+        mock.characterExtractionCandidatesResult = .success(candidates)+        let client = MutableAvailabilityClient(availability, inner: stub)+        let environment = StubEnvironment()+        // A lane of its own: `ModelLane.shared` is app-wide by design, and a+        // suite sharing one slot across cases would couple them.+        let coordinator = CharacterExtractionCoordinator(+            library: mock, model: client, environment: environment, lane: ModelLane())+        return (coordinator, mock, client, environment)+    }++    // MARK: - The activation sweep (Req 1.1, 1.2)++    @Test("The sweep processes at most the bounded number of works, newest activity first")+    func sweepRespectsWorksPerActivationAndOrder() async {+        let newest = UUID(), middle = UUID(), oldest = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(workID: oldest, recency: 10),+                Self.candidate(workID: newest, recency: 90),+                Self.candidate(workID: middle, recency: 50),+            ],+            stub: stub)++        await coordinator.activationSweep()++        #expect(CharacterExtractionBounds.worksPerActivation == 2)+        let workIDs = recorder.recordedSources.map(\.workID)+        #expect(workIDs == [newest, middle])+        #expect(coordinator.held(for: oldest).isEmpty)+    }++    /// Q106: coverage alone was not enough. A refusal covers nothing, so a work+    /// whose every source had already been attempted this run stayed "eligible"+    /// and kept one of the two slots on every activation for the rest of the run+    /// — the work behind it was never reached.+    @Test("A work whose uncovered sources were all attempted stops taking a sweep slot")+    func attemptedWorksReleaseTheirSlot() async {+        let workA = UUID(), workB = UUID(), workC = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(+            error: StubCharacterExtractionModelClientError.refused, recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(workID: workA, recency: 90),+                Self.candidate(workID: workB, recency: 50),+                Self.candidate(workID: workC, recency: 10),+            ],+            stub: stub)++        await coordinator.activationSweep()+        #expect(recorder.recordedSources.map(\.workID) == [workA, workB])++        await coordinator.activationSweep()++        #expect(recorder.recordedSources.map(\.workID) == [workA, workB, workC])+    }++    @Test("The sweep asks the library for no more works than it may examine")+    func sweepBoundsTheCandidateRead() async {+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate()])++        await coordinator.activationSweep()++        #expect(mock.lastCharacterExtractionLimit == CharacterExtractionBounds.worksExamined)+        // nil is the whole-library read; a set is `reconcile`'s tracked pass.+        #expect(mock.lastCharacterExtractionWorkIDs == .some(nil))+    }++    @Test("A request carries the work's title and one source's text, and nothing else")+    func requestCarriesOnlyTheSource() async {+        let workID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(+                    workID: workID, title: "Lanterns", notes: "Ada carried the lantern.")+            ],+            stub: stub)++        await coordinator.activationSweep()++        #expect(recorder.callCount == 1)+        let request = recorder.recordedSources.first+        #expect(request?.workTitle == "Lanterns")+        #expect(request?.text == "Ada carried the lantern.")+        #expect(request?.source == .genericNotes)+    }++    @Test("A covered source is not re-processed")+    func sweepSkipsCoveredSources() async {+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.candidate(covered: true)], stub: stub)++        await coordinator.activationSweep()++        #expect(recorder.callCount == 0)+    }++    @Test("Held proposals are the assembled rows the reader will decide on")+    func sweepHoldsAssembledProposals() async {+        let workID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])++        await coordinator.activationSweep()++        let held = coordinator.held(for: workID)+        #expect(held.count == 1)+        #expect(held.first?.name == "Ada")+        #expect(held.first?.facts.count == 1)+        #expect(coordinator.hasProposals(for: workID))+    }++    // MARK: - The gates (Req 1.2)++    @Test("Low Power Mode stops the sweep before any model call")+    func sweepGatedByLowPowerMode() async {+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, environment) = makeSUT(+            candidates: [Self.candidate()], stub: stub)+        environment.isLowPowerModeEnabled = true++        await coordinator.activationSweep()++        #expect(recorder.callCount == 0)+    }++    @Test("A serious thermal state stops the sweep; a fair one does not")+    func sweepGatedByThermalState() async {+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, environment) = makeSUT(+            candidates: [Self.candidate()], stub: stub)+        environment.thermalState = .serious++        await coordinator.activationSweep()+        #expect(recorder.callCount == 0)++        environment.thermalState = .fair+        await coordinator.activationSweep()+        #expect(recorder.callCount == 1)+    }++    @Test("An inactive app runs no sweep")+    func sweepGatedByActiveState() async {+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, environment) = makeSUT(+            candidates: [Self.candidate()], stub: stub)+        environment.isActive = false++        await coordinator.activationSweep()++        #expect(recorder.callCount == 0)+    }++    @Test("An unavailable model sweeps nothing and reports itself away")+    func sweepSkippedWhileModelUnavailable() async {+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, mock, client, _) = makeSUT(+            candidates: [Self.candidate()],+            availability: .unavailable(reason: "test"),+            stub: stub)++        await coordinator.activationSweep()++        #expect(!coordinator.isModelAvailable)+        #expect(recorder.callCount == 0)+        // Req 1.2's read bound is not even paid for.+        #expect(mock.characterExtractionCandidatesCallCount == 0)++        // Availability is transient, so it is re-read every activation.+        client.availabilityResult = .available+        await coordinator.activationSweep()+        #expect(coordinator.isModelAvailable)+        #expect(recorder.callCount == 1)+    }++    // MARK: - Produced-none coverage (Req 4.3, Q65)++    @Test("A source the pass produced nothing for is covered at pass time")+    func producedNoneCoversAtPassTime() async {+        let workID = UUID()+        let notes = "Nothing here names anyone."+        let stub = StubCharacterExtractionModelClient(result: ExtractionResult(characters: []))+        let (coordinator, mock, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID, notes: notes)], stub: stub)++        await coordinator.activationSweep()++        #expect(coordinator.held(for: workID).isEmpty)+        #expect(mock.advancedCoverage.count == 1)+        let advance = mock.advancedCoverage.first+        #expect(advance?.workID == workID)+        #expect(advance?.sources.map(\.ref) == [.genericNotes])+        #expect(advance?.sources.first?.fingerprint == CharacterCoverageFingerprint.of(notes))+    }++    @Test("A source with shown proposals is left for the decision to cover")+    func shownProposalsAreNotCoveredAtPassTime() async {+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate()])++        await coordinator.activationSweep()++        #expect(mock.advancedCoverage.isEmpty)+    }++    @Test("A source whose whole output is filtered away still covers")+    func filteredEmptyStillCovers() async {+        let workID = UUID()+        // The only fact the model offers is one the reader already accepted, so+        // the row empties and there is nothing left to decide (Req 1.7).+        let accepted = CharacterFactIdentity(+            nameKey: "ada", source: .genericNotes, quote: "Ada carried the lantern")+        let (coordinator, mock, _, _) = makeSUT(+            candidates: [+                Self.candidate(+                    workID: workID,+                    characters: [+                        CharacterMatchTarget(+                            id: UUID(), currentNameKey: "ada", retainedKey: "ada",+                            aliasKeys: [], isTorn: false)+                    ],+                    acceptedFacts: [accepted])+            ])++        await coordinator.activationSweep()++        #expect(coordinator.held(for: workID).isEmpty)+        #expect(mock.advancedCoverage.count == 1)+    }++    // MARK: - Failure (Req 1.8)++    @Test("A failed source is skipped, left uncovered, and not retried this run")+    func failedSourceIsSkippedAndRemembered() async {+        let workID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(+            error: StubCharacterExtractionModelClientError.refused, recorder: recorder)+        let (coordinator, mock, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID)], stub: stub)++        await coordinator.activationSweep()+        #expect(coordinator.held(for: workID).isEmpty)+        #expect(mock.advancedCoverage.isEmpty)+        #expect(recorder.callCount == 1)++        // Req 1.8: not retried within the same app run.+        await coordinator.activationSweep()+        #expect(recorder.callCount == 1)+    }++    @Test("An oversized source is skipped rather than truncated")+    func oversizedSourceIsSkipped() async {+        let workID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(+            error: StubCharacterExtractionModelClientError.contextWindowOverflow,+            recorder: recorder)+        let (coordinator, mock, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID)], stub: stub)++        await coordinator.activationSweep()++        #expect(recorder.callCount == 1)+        #expect(coordinator.held(for: workID).isEmpty)+        // Uncovered: never truncated and marked covered (Req 1.5).+        #expect(mock.advancedCoverage.isEmpty)+    }++    @Test("A failed candidate read skips the activation in silence")+    func failedCandidateReadSkipsSweep() async {+        let (coordinator, mock, _, _) = makeSUT()+        mock.characterExtractionCandidatesResult = .failure(MockLibraryProvider.MockError.notConfigured)++        await coordinator.activationSweep()++        #expect(coordinator.worksWithProposals.isEmpty)+    }++    // MARK: - Invalidation (Req 2.8)++    @Test("Reconcile drops held proposals whose work is gone")+    func reconcileDropsDeletedWork() async {+        let workID = UUID()+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()+        #expect(coordinator.hasProposals(for: workID))++        mock.characterExtractionCandidatesResult = .success([])+        await coordinator.reconcile()++        #expect(!coordinator.hasProposals(for: workID))+    }++    @Test("Reconcile drops a proposal whose cited note has been edited")+    func reconcileDropsChangedRevision() async {+        let workID = UUID()+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()+        #expect(coordinator.hasProposals(for: workID))++        mock.characterExtractionCandidatesResult = .success([+            Self.candidate(workID: workID, notes: "Ada carried the lantern, and a rope.")+        ])+        await coordinator.reconcile()++        #expect(!coordinator.hasProposals(for: workID))+    }++    @Test("Reconcile asks only about the works something is held or attempted for")+    func reconcileReadsOnlyTrackedWorks() async {+        let workID = UUID()+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()++        await coordinator.reconcile()++        #expect(mock.lastCharacterExtractionWorkIDs == .some(Set([workID])))+    }++    @Test("Reconcile with nothing tracked reads nothing")+    func reconcileNoOpWhenNothingTracked() async {+        let (coordinator, mock, _, _) = makeSUT()++        await coordinator.reconcile()++        #expect(mock.characterExtractionCandidatesCallCount == 0)+    }++    // MARK: - Lifecycle++    @Test("A memory warning drops what is held")+    func memoryWarningDropsHeld() async {+        let workID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()+        #expect(coordinator.hasProposals(for: workID))++        coordinator.memoryWarning()++        #expect(!coordinator.hasProposals(for: workID))+    }++    /// Q110: the stop signal is checked **per source**, not per work.+    ///+    /// The old shape of this test called `resignActive()` *before* the sweep and+    /// asserted both works were processed — which asserts only that a stop taken+    /// while no sweep is running stops nothing. What matters is the stop taken+    /// while a work is half-processed: `environment.isActive` is still `.active`+    /// at `willResignActive`, so nothing else stopped the work's next source+    /// starting a fresh attempt on the way to the background.+    @Test("Resigning active mid-work stops the sweep before the work's next source")+    func resignActiveStopsBetweenSources() async {+        let workID = UUID(), entryID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, client, _) = makeSUT(+            candidates: [Self.twoSourceCandidate(workID: workID, entryID: entryID)], stub: stub)+        client.onExtract = { [weak coordinator] _ in+            await MainActor.run { coordinator?.resignActive() }+        }++        await coordinator.activationSweep()++        #expect(recorder.callCount == 1, "the second source must not be attempted")+        #expect(recorder.recordedSources.first?.source == .genericNotes)++        // The stop ends *that* sweep and nothing else: the source it spared is+        // still uncovered, and the next activation reaches it.+        client.onExtract = nil+        await coordinator.activationSweep()+        #expect(+            recorder.recordedSources.contains { $0.source == .entry(entryID) },+            "the next activation picks up the source the stop spared")+    }++    // MARK: - The manual pass (Req 1.11)++    @Test("A manual pass processes a covered source the sweep would leave alone")+    func manualPassIgnoresCoverage() async {+        let workID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID, covered: true)], stub: stub)++        await coordinator.runManualPass(workID: workID)++        #expect(recorder.callCount == 1)+        #expect(coordinator.held(for: workID).count == 1)+        #expect(coordinator.manualOutcome(for: workID) == .proposals(1))+    }++    @Test("A manual pass gets past a name-key suppression")+    func manualPassIgnoresSuppression() async {+        let workID = UUID()+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(+                    workID: workID,+                    suppressions: CharacterSuppressionIndex(+                        candidateKeys: ["ada"], factIdentities: []))+            ])++        await coordinator.runManualPass(workID: workID)++        #expect(coordinator.held(for: workID).count == 1)+    }++    @Test("A manual pass still refuses to re-propose an accepted fact")+    func manualPassDedupsAcceptedFacts() async {+        let workID = UUID()+        let accepted = CharacterFactIdentity(+            nameKey: "ada", source: .genericNotes, quote: "Ada carried the lantern")+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(+                    workID: workID,+                    characters: [+                        CharacterMatchTarget(+                            id: UUID(), currentNameKey: "ada", retainedKey: "ada",+                            aliasKeys: [], isTorn: false)+                    ],+                    acceptedFacts: [accepted])+            ])++        await coordinator.runManualPass(workID: workID)++        #expect(coordinator.held(for: workID).isEmpty)+        #expect(coordinator.manualOutcome(for: workID) == .noProposals)+    }++    @Test("A manual pass covers what it produced nothing for, and never regresses coverage")+    func manualPassAdvancesCoverage() async {+        let workID = UUID()+        let stub = StubCharacterExtractionModelClient(result: ExtractionResult(characters: []))+        let (coordinator, mock, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID, covered: true)], stub: stub)++        await coordinator.runManualPass(workID: workID)++        // The write is the repository's, and it only ever moves a fingerprint to+        // the text the source currently holds — so "never regresses" is a+        // property of what is sent, not of what the coordinator decides.+        #expect(mock.advancedCoverage.count == 1)+        #expect(mock.advancedCoverage.first?.workID == workID)+        #expect(coordinator.manualOutcome(for: workID) == .noProposals)+    }++    @Test("A manual pass runs with the sweep budget spent")+    func manualPassIsExemptFromTheBudget() async {+        let workID = UUID()+        let recorder = StubCharacterExtractionModelClient.Recorder()+        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID)], stub: stub)+        coordinator.exhaustBudgetForTesting()++        await coordinator.activationSweep()+        #expect(recorder.callCount == 0)++        await coordinator.runManualPass(workID: workID)+        #expect(recorder.callCount == 1)+    }++    @Test("A manual pass charges the sweep budget nothing", .timeLimit(.minutes(1)))+    func manualPassChargesNoBudget() async {+        let workID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])++        await coordinator.runManualPass(workID: workID)++        #expect(coordinator.budgetSpent == .zero)+    }++    @Test("A failed manual pass ends with the same visible outcome as an empty one")+    func manualPassFailureReadsAsNoProposals() async {+        let workID = UUID()+        let stub = StubCharacterExtractionModelClient(+            error: StubCharacterExtractionModelClientError.refused)+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.candidate(workID: workID)], stub: stub)++        await coordinator.runManualPass(workID: workID)++        #expect(coordinator.manualOutcome(for: workID) == .noProposals)+        #expect(coordinator.held(for: workID).isEmpty)+    }++    @Test("A manual pass on a work the library no longer offers ends with no proposals")+    func manualPassOnMissingWorkReadsAsNoProposals() async {+        let (coordinator, _, _, _) = makeSUT(candidates: [])++        let workID = UUID()+        await coordinator.runManualPass(workID: workID)++        #expect(coordinator.manualOutcome(for: workID) == .noProposals)+    }++    @Test("The manual trigger is not offered while the model is away")+    func manualTriggerHiddenWhileUnavailable() async {+        let (coordinator, _, client, _) = makeSUT(availability: .unavailable(reason: "test"))++        #expect(!coordinator.canRunManualPass)++        client.availabilityResult = .available+        await coordinator.activationSweep()+        #expect(coordinator.canRunManualPass)+    }++    @Test("Dismissing a manual outcome clears it")+    func manualOutcomeIsDismissible() async {+        let workID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])++        await coordinator.runManualPass(workID: workID)+        #expect(coordinator.manualOutcome(for: workID) != nil)++        coordinator.clearManualOutcome(for: workID)+        #expect(coordinator.manualOutcome(for: workID) == nil)+    }++    // MARK: - Decisions++    @Test("Discarding a decided row leaves its siblings held")+    func discardRemovesOneRow() async {+        let workID = UUID()+        let stub = StubCharacterExtractionModelClient(+            result: ExtractionResult(characters: [+                ExtractedCharacter(+                    name: "Ada",+                    facts: [ExtractedFact(statement: "Ada carried a lantern.",+                                          quote: "Ada carried the lantern")]),+                ExtractedCharacter(+                    name: "Brede",+                    facts: [ExtractedFact(statement: "Brede followed.", quote: "Brede followed")]),+            ]))+        let (coordinator, _, _, _) = makeSUT(+            candidates: [+                Self.candidate(+                    workID: workID, notes: "Ada carried the lantern. Brede followed.")+            ],+            stub: stub)++        await coordinator.activationSweep()+        #expect(coordinator.held(for: workID).count == 2)++        coordinator.discard(nameKey: "ada", for: workID)++        #expect(coordinator.held(for: workID).map(\.nameKey) == ["brede"])+    }+}
Asterism/AsterismTests/CharacterReviewModelTests.swift Added +559 / -0
diff --git a/Asterism/AsterismTests/CharacterReviewModelTests.swift b/Asterism/AsterismTests/CharacterReviewModelTests.swiftnew file mode 100644index 0000000..a6cfc55--- /dev/null+++ b/Asterism/AsterismTests/CharacterReviewModelTests.swift@@ -0,0 +1,559 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import Testing+@testable import Asterism++/// Tests for `CharacterReviewModel`: the sheet that turns held proposals into+/// decisions (Reqs 2.1, 2.2, 2.5, 2.7).+///+/// The commit itself is the repository's and is covered against a real store in+/// `CharacterExtractionRepositoryTests`. What is pinned here is what the sheet+/// *sends* — the displayed keys, the struck aliases, the unticked facts — and+/// what it does with what comes back.+@Suite("CharacterReviewModel")+@MainActor+struct CharacterReviewModelTests {++    // MARK: - Fixtures++    private nonisolated static func fact(+        _ statement: String, quote: String, key: String = "ada",+        source: SourceRef = .genericNotes+    ) -> GroundedFact {+        GroundedFact(nameKey: key, statement: statement, quote: quote, source: source)+    }++    private nonisolated static func candidate(+        name: String = "Ada", key: String = "ada", aliases: [String] = [],+        facts: [GroundedFact]? = nil,+        target: ExtractionProposal.Target = .newCharacter,+        revisions: [SourceRef: String] = [.genericNotes: "fp-1"]+    ) -> ExtractionProposal {+        ExtractionProposal(+            name: name, nameKey: key, proposedAliases: aliases, target: target,+            facts: facts ?? [fact("Ada keeps the light.", quote: "Ada keeps the light", key: key)],+            citedRevisions: revisions)+    }++    private nonisolated static func character(+        id: UUID, name: String = "Ada", facts: [CharacterFact] = []+    ) -> WorkCharacterPresentation {+        WorkCharacterPresentation(+            id: id, name: name, note: "", aliases: [],+            nameKey: CharacterNameKey.normalize(name),+            facts: facts.map {+                WorkCharacterFactRow(+                    id: $0.quote, statement: $0.statement, quote: $0.quote, source: $0.source,+                    citedEntryID: nil, citationTitle: nil, isDangling: false, fact: $0)+            },+            isTorn: false, rowCount: 1,+            editBasis: CharacterEditBasis(+                characterID: id, name: name, note: "", aliases: [], facts: facts))+    }++    private func makeSUT(+        workID: UUID = UUID(),+        proposals: [ExtractionProposal] = [CharacterReviewModelTests.candidate()],+        characters: [WorkCharacterPresentation] = []+    ) -> (CharacterReviewModel, MockLibraryProvider) {+        let mock = MockLibraryProvider()+        let model = CharacterReviewModel(+            workID: workID, proposals: proposals, characters: characters, library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })+        return (model, mock)+    }++    // MARK: - Fixtures for the refusal cases that cross the real seam++    @MainActor+    final class StubEnvironment: SuggestionEnvironment {+        var isActive = true+        var isLowPowerModeEnabled = false+        var thermalState: ProcessInfo.ThermalState = .nominal+        var now: ContinuousClock.Instant { ContinuousClock.now }+    }++    /// The library row an activation sweep reads, naming Ada in the generic+    /// notes. `characters` are the work's existing characters, which is what+    /// decides whether the assembled proposal is a candidate or a bundle.+    private nonisolated static func extractionCandidate(+        workID: UUID,+        notes: String = "Ada keeps the light.",+        characters: [CharacterMatchTarget] = []+    ) -> CharacterExtractionCandidate {+        CharacterExtractionCandidate(+            workID: workID,+            displayTitle: "The Lamp Room",+            recency: Date(timeIntervalSince1970: 0),+            sources: [+                CharacterExtractionSource(+                    ref: .genericNotes, text: notes,+                    fingerprint: CharacterCoverageFingerprint.of(notes),+                    coveredFingerprint: nil)+            ],+            characters: characters,+            acceptedFactIdentities: [],+            suppressions: .empty)+    }++    private nonisolated static func workPresentation(+        workID: UUID, characters: [WorkCharacterPresentation]+    ) -> WorkDetailPresentation {+        let base = TestFixtures.makeWorkDetail(work: TestFixtures.makeWork(id: workID))+        return WorkDetailPresentation(+            work: base.work, pulse: base.pulse,+            lastNotedURLString: base.lastNotedURLString, chapterRows: base.chapterRows,+            characters: characters)+    }++    /// A coordinator holding what a real sweep assembled over `library`.+    ///+    /// The refusal cases below drive the coordinator's own held state rather+    /// than a proposal list the test hands back from `refresh`: what is being+    /// pinned is that the *coordinator* changes, and a fixture returning a+    /// pre-retargeted row proves only that the row builder works.+    private func sweptCoordinator(+        library: MockLibraryProvider+    ) async -> CharacterExtractionCoordinator {+        let coordinator = CharacterExtractionCoordinator(+            library: library,+            model: StubCharacterExtractionModelClient(+                result: ExtractionResult(characters: [+                    ExtractedCharacter(+                        name: "Ada",+                        facts: [+                            ExtractedFact(+                                statement: "Ada keeps the light.", quote: "Ada keeps the light")+                        ])+                ])),+            environment: StubEnvironment(),+            lane: ModelLane())+        await coordinator.activationSweep()+        return coordinator+    }++    // MARK: - Presentation (Req 2.1)++    @Test("The sheet opens on the proposals it was given")+    func opensOnItsProposals() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Ada", key: "ada"),+                        Self.candidate(name: "Brede", key: "brede")])++        #expect(model.rows.map(\.name) == ["Ada", "Brede"])+        #expect(!model.isEmpty)+    }++    @Test("A bundle row shows the target character's existing facts beside the proposals")+    func bundleShowsExistingFacts() {+        let characterID = UUID()+        let existing = CharacterFact(+            statement: "Ada arrived by sea.", quote: "arrived by sea", nameKey: "ada",+            source: .genericNotes)+        let (model, _) = makeSUT(+            proposals: [Self.candidate(target: .existing(characterID))],+            characters: [Self.character(id: characterID, facts: [existing])])++        let row = model.rows.first+        #expect(row?.isBundle == true)+        #expect(row?.existingFacts.map(\.statement) == ["Ada arrived by sea."])+        #expect(row?.proposedFacts.map(\.statement) == ["Ada keeps the light."])+    }++    @Test("A candidate row shows no existing facts")+    func candidateShowsNoExistingFacts() {+        let (model, _) = makeSUT()++        #expect(model.rows.first?.isBundle == false)+        #expect(model.rows.first?.existingFacts.isEmpty == true)+    }++    @Test("A row with proposed aliases shows them")+    func aliasesAreShown() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Ada", aliases: ["Nightjar"])])++        #expect(model.rows.first?.aliases.map(\.name) == ["Nightjar"])+        #expect(model.rows.first?.aliases.first?.isStruck == false)+    }++    // MARK: - Snapshot at open (Req 2.7)++    @Test("A later sweep's proposals do not change the open sheet")+    func snapshotAtOpen() {+        let workID = UUID()+        let mock = MockLibraryProvider()+        nonisolated(unsafe) var latest = [Self.candidate(name: "Ada", key: "ada")]+        let model = CharacterReviewModel(+            workID: workID, proposals: latest, characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { latest })++        latest.append(Self.candidate(name: "Brede", key: "brede"))++        #expect(model.rows.map(\.name) == ["Ada"])+    }++    // MARK: - Display order (AC 2.1, Q88)++    /// A row aggregating two sources (Q83/Q100) arrives in the pipeline's+    /// **canonical** order (Q75), which puts entry citations in UUID order. That+    /// is right for merging, dedup and encoding and wrong on screen: the reader+    /// reads a character's facts as history, and the work page shows exactly+    /// this order once the facts are kept.+    @Test("A merged row's facts are displayed in the notes' capture order")+    func mergedRowOrdersFactsByCaptureOrder() {+        // Deliberately opposed: the note captured *second* holds the UUID that+        // sorts *first*, so a display keyed on the citation's UUID would put it+        // above the note that came before it.+        let capturedSecond = UUID(uuidString: "0E000000-0000-4000-8000-000000000001")!+        let capturedFirst = UUID(uuidString: "0E000000-0000-4000-8000-000000000002")!+        let proposal = Self.candidate(+            facts: [+                // As the ledger's merge left them (`isOrderedBefore`).+                Self.fact("Ada arrives.", quote: "Ada arrives", source: .entry(capturedSecond)),+                Self.fact("Ada leaves.", quote: "Ada leaves", source: .entry(capturedFirst)),+                Self.fact("Ada keeps the light.", quote: "Ada keeps the light"),+            ],+            revisions: [+                .genericNotes: "fp-0",+                .entry(capturedSecond): "fp-2",+                .entry(capturedFirst): "fp-1",+            ])+        let model = CharacterReviewModel(+            workID: UUID(), proposals: [proposal], characters: [],+            captureOrder: [capturedFirst: 0, capturedSecond: 1],+            library: MockLibraryProvider(),+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })++        #expect(+            model.rows.first?.proposedFacts.map(\.source)+                == [.genericNotes, .entry(capturedFirst), .entry(capturedSecond)],+            """+            Q88: generic notes first, then live citations in capture order — \+            not the entry-UUID order the canonical encoding uses+            """)+    }++    // MARK: - Ticks and strikes (Req 2.2, Q92)++    @Test("Unticking a fact keeps it displayed and sends it as unticked")+    func untickingSendsTheFactSeparately() async {+        let (model, mock) = makeSUT()+        guard let row = model.rows.first, let fact = row.proposedFacts.first else {+            Issue.record("Expected a proposed fact")+            return+        }++        model.setTicked(false, factID: fact.id, in: row.id)+        #expect(model.rows.first?.proposedFacts.first?.isTicked == false)++        await model.accept(row.id)++        let request = mock.committedCharacterDecisions.first+        #expect(request?.facts.isEmpty == true)+        #expect(request?.untickedFacts.count == 1)+    }++    @Test("A struck alias is not among the keys the decision displays")+    func strikingAnAliasRemovesItsKey() async {+        let (model, mock) = makeSUT(+            proposals: [Self.candidate(name: "Ada", aliases: ["Nightjar", "Lampkeeper"])])+        guard let row = model.rows.first else {+            Issue.record("Expected a row")+            return+        }++        model.setStruck(true, alias: "Nightjar", in: row.id)+        await model.accept(row.id)++        let request = mock.committedCharacterDecisions.first+        #expect(request?.proposedAliases == ["Lampkeeper"])+        #expect(request?.displayedKeys == ["ada", "lampkeeper"])+    }++    @Test("Skipping suppresses exactly the keys the row displayed at skip time")+    func skipSuppressesTheDisplayedKeys() async {+        let (model, mock) = makeSUT(+            proposals: [Self.candidate(name: "Ada", aliases: ["Nightjar"])])+        guard let row = model.rows.first else {+            Issue.record("Expected a row")+            return+        }++        model.setStruck(true, alias: "Nightjar", in: row.id)+        await model.skip(row.id)++        let request = mock.committedCharacterDecisions.first+        #expect(request?.action == .skip)+        // The struck alias's key is not among them (Q92).+        #expect(request?.displayedKeys == ["ada"])+    }++    @Test("A decision carries the sources it completes")+    func decisionCarriesCompletedSources() async {+        let entryID = UUID()+        let (model, mock) = makeSUT(+            proposals: [+                Self.candidate(+                    facts: [+                        Self.fact("Ada keeps the light.", quote: "Ada keeps the light"),+                        Self.fact("Ada rows out.", quote: "Ada rows out", source: .entry(entryID)),+                    ],+                    revisions: [.genericNotes: "fp-1", .entry(entryID): "fp-2"])+            ])+        guard let row = model.rows.first else {+            Issue.record("Expected a row")+            return+        }++        await model.accept(row.id)++        let sources = mock.committedCharacterDecisions.first?.completedSources ?? []+        #expect(sources.count == 2)+        #expect(Set(sources.map(\.ref)) == Set([.genericNotes, .entry(entryID)]))+    }++    // MARK: - Committing (Req 2.2, 2.5)++    @Test("A committed decision leaves the sheet and tells the coordinator which row it was")+    func commitReportsTheDecidedRow() async {+        nonisolated(unsafe) var decided: [String] = []+        let mock = MockLibraryProvider()+        let model = CharacterReviewModel(+            workID: UUID(),+            proposals: [Self.candidate(name: "Ada", key: "ada"),+                        Self.candidate(name: "Brede", key: "brede")],+            characters: [], library: mock,+            onDecision: { decided.append($0) }, onReRoute: { _, _ in }, refresh: { [] })++        await model.accept("ada")++        #expect(decided == ["ada"])+        #expect(model.rows.map(\.name) == ["Brede"])+    }++    @Test("Deciding the last row empties the sheet")+    func lastDecisionEmptiesTheSheet() async {+        let (model, _) = makeSUT()++        await model.accept("ada")++        #expect(model.isEmpty)+    }++    @Test("Undecided rows survive dismissal")+    func undecidedRowsSurviveDismissal() async {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Ada", key: "ada"),+                        Self.candidate(name: "Brede", key: "brede")])++        await model.accept("ada")+        model.dismiss()++        // Nothing was discarded on the way out: the coordinator still holds+        // Brede, and the next open re-presents it (Req 2.6).+        #expect(model.rows.map(\.name) == ["Brede"])+    }++    // MARK: - Refusals (Req 2.7, 2.8)++    /// Req 2.7/Q110: "The list below is up to date" is a promise the refresh has+    /// to keep. Nothing else on the work page reaches the coordinator's+    /// invalidation, so without the reconcile the stale row was re-presented+    /// byte-identical and refused again on the next Keep.+    @Test("A stale refusal reconciles the coordinator, and the stale row goes")+    func staleRefusalInvalidatesTheRow() async {+        let workID = UUID()+        let mock = MockLibraryProvider()+        mock.characterExtractionCandidatesResult = .success([+            Self.extractionCandidate(workID: workID)+        ])+        let coordinator = await sweptCoordinator(library: mock)+        #expect(coordinator.hasProposals(for: workID), "the sweep held a real proposal")++        let model = CharacterReviewModel(+            workID: workID, proposals: coordinator.held(for: workID), characters: [],+            library: mock,+            onDecision: { coordinator.discard(nameKey: $0, for: workID) },+            onReRoute: { key, target in+                coordinator.retarget(nameKey: key, for: workID, to: target)+            },+            refresh: {+                await coordinator.reconcile()+                return coordinator.held(for: workID)+            })++        // The note the proposal cites has been edited since it was assembled.+        mock.characterExtractionCandidatesResult = .success([+            Self.extractionCandidate(workID: workID, notes: "Ada keeps the light, and the door.")+        ])+        mock.workDetailResult = .success(Self.workPresentation(workID: workID, characters: []))+        mock.commitCharacterDecisionResult = .success(.refused(.staleSource(.genericNotes)))++        await model.accept("ada")++        #expect(model.disclosure?.contains("changed") == true)+        #expect(model.isEmpty, "the stale row is gone from the sheet")+        #expect(!coordinator.hasProposals(for: workID), "and from what the coordinator holds")+    }++    /// Q66/Q110: the refusal carries the character the row really resolves onto,+    /// and applying it to the **coordinator's** held row is the whole fix. A+    /// refresh that re-read the unchanged row looped the reader Keep → refuse →+    /// Keep for ever.+    @Test("A re-routed refusal retargets the held row and re-presents it as a bundle")+    func reRoutedRefusalRetargetsTheHeldRow() async {+        let workID = UUID(), characterID = UUID()+        let mock = MockLibraryProvider()+        // The work already has a character under a different key, so the sweep's+        // proposal is assembled as a *candidate* — which is the state the+        // re-route has to move.+        mock.characterExtractionCandidatesResult = .success([+            Self.extractionCandidate(+                workID: workID,+                characters: [+                    CharacterMatchTarget(+                        id: characterID, currentNameKey: "ada vance", retainedKey: "ada vance",+                        aliasKeys: [], isTorn: false)+                ])+        ])+        let coordinator = await sweptCoordinator(library: mock)+        #expect(coordinator.held(for: workID).first?.target == .newCharacter)++        let existing = CharacterFact(+            statement: "Ada arrived by sea.", quote: "arrived by sea", nameKey: "ada vance",+            source: .genericNotes)+        // The sheet's own snapshot has no characters in it: the resolving one may+        // have been created seconds ago by a sibling accept in this very sheet.+        let model = CharacterReviewModel(+            workID: workID, proposals: coordinator.held(for: workID), characters: [],+            library: mock,+            onDecision: { coordinator.discard(nameKey: $0, for: workID) },+            onReRoute: { key, target in+                coordinator.retarget(nameKey: key, for: workID, to: target)+            },+            refresh: {+                await coordinator.reconcile()+                return coordinator.held(for: workID)+            })+        mock.workDetailResult = .success(+            Self.workPresentation(+                workID: workID,+                characters: [+                    Self.character(id: characterID, name: "Ada Vance", facts: [existing])+                ]))+        mock.commitCharacterDecisionResult = .success(.refused(.reRouted(to: characterID)))++        await model.accept("ada")++        #expect(model.disclosure != nil)+        #expect(+            coordinator.held(for: workID).first?.target == .existing(characterID),+            "the retarget is what changed; the refresh only re-read it")+        let row = model.rows.first+        #expect(model.rows.count == 1)+        #expect(row?.isBundle == true)+        // Drawn against the *re-read* characters, not the open-time snapshot.+        #expect(row?.name == "Ada Vance")+        #expect(row?.existingFacts.map(\.statement) == ["Ada arrived by sea."])+    }++    @Test("A torn refusal discloses and routes the reader to Check Library")+    func tornRefusalRoutes() async {+        let mock = MockLibraryProvider()+        mock.commitCharacterDecisionResult = .success(.refused(.torn(characterID: UUID())))+        let model = CharacterReviewModel(+            workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [Self.candidate()] })++        await model.accept("ada")++        #expect(model.disclosure != nil)+        #expect(model.routesToCheckLibrary)+        // Acceptance was refused, so the row is still there to decide.+        #expect(model.rows.count == 1)+    }++    @Test("A torn work still takes a skip")+    func tornWorkStillTakesASkip() async {+        let mock = MockLibraryProvider()+        mock.commitCharacterDecisionResult = .success(.committed(characterID: nil))+        let model = CharacterReviewModel(+            workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })++        await model.skip("ada")++        #expect(model.isEmpty)+        #expect(mock.committedCharacterDecisions.first?.action == .skip)+    }++    @Test("A vanished work closes the sheet")+    func workGoneClosesTheSheet() async {+        let mock = MockLibraryProvider()+        mock.commitCharacterDecisionResult = .success(.refused(.workGone))+        let model = CharacterReviewModel(+            workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })++        await model.accept("ada")++        #expect(model.isEmpty)+    }++    @Test("A thrown commit keeps the row and says so")+    func thrownCommitKeepsTheRow() async {+        let mock = MockLibraryProvider()+        mock.commitCharacterDecisionResult = .failure(MockLibraryProvider.MockError.notConfigured)+        let model = CharacterReviewModel(+            workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [Self.candidate()] })++        await model.accept("ada")++        #expect(model.disclosure != nil)+        #expect(model.rows.count == 1)+    }++    /// Q92's strikes belong to the row that drew them. Matched by alias *name*+    /// across the whole list, one row's strike travelled to every other row+    /// proposing the same string on the first refresh — and a suppressed alias+    /// mis-routes for ever, which is the reason strikes exist.+    @Test("A strike does not travel to another row proposing the same alias")+    func strikesAreScopedToTheirRow() async {+        // The boundary here is the sheet's own refresh bookkeeping, so the+        // refreshed list is deliberately identical to the opened one: what must+        // not survive the round trip is Brede's borrowed strike.+        let held = [+            Self.candidate(name: "Ada", key: "ada", aliases: ["Nightjar"]),+            Self.candidate(name: "Brede", key: "brede", aliases: ["Nightjar"]),+        ]+        let workID = UUID()+        let mock = MockLibraryProvider()+        mock.commitCharacterDecisionResult = .success(.refused(.staleSource(.genericNotes)))+        mock.workDetailResult = .success(Self.workPresentation(workID: workID, characters: []))+        let model = CharacterReviewModel(+            workID: workID, proposals: held, characters: [], library: mock,+            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { held })++        model.setStruck(true, alias: "Nightjar", in: "ada")+        await model.accept("ada")++        #expect(model.rows.first { $0.id == "ada" }?.aliases.first?.isStruck == true)+        #expect(+            model.rows.first { $0.id == "brede" }?.aliases.first?.isStruck == false,+            "Brede's alias is Brede's decision")+    }++    @Test("A second decision while one is in flight is suppressed")+    func duplicateSubmissionSuppressed() async {+        let (model, mock) = makeSUT()++        async let first: Void = model.accept("ada")+        await model.accept("ada")+        await first++        #expect(mock.committedCharacterDecisions.count == 1)+    }+}
Asterism/AsterismTests/ComposedTeachingViewModelTests.swift Modified +2 / -1
diff --git a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swiftindex fce2b4a..4ffa5b9 100644--- a/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift+++ b/Asterism/AsterismTests/ComposedTeachingViewModelTests.swift@@ -1074,7 +1074,8 @@ struct ComposedTeachingViewModelTests {             library: mock,             model: StubRuleSuggestionModelClient(availability: availability),             suggester: suggester,-            environment: RuleSuggestionCoordinatorTests.StubEnvironment())+            environment: RuleSuggestionCoordinatorTests.StubEnvironment(),+            lane: ModelLane())         if let held {             suggester.suggestions[Self.hostname] = held             _ = await coordinator.suggestion(for: Self.hostname, origin: .background)
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +97 / -0
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex a9d6530..a9044f7 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -212,6 +212,103 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {         return rows.filter { hostnames.contains($0.hostname) }     } +    // MARK: - Character extraction++    /// Recorded under `recorderLock` for the same reason the rule-suggestion+    /// read is: the sweep, `reconcile()` and a manual pass can all be inside+    /// these at once.+    var characterExtractionCandidatesResult:+        Result<[CharacterExtractionCandidate], Error> = .success([])+    var advanceCharacterCoverageResult: Result<Int, Error> = .success(1)+    var commitCharacterDecisionResult: Result<CharacterDecisionOutcome, Error> =+        .success(.committed(characterID: nil))+    var commitCharacterEditsResult: Result<CharacterEditOutcome, Error> =+        .success(.committed(characterIDs: []))++    var characterExtractionCandidatesCallCount: Int {+        recorderLock.withLock { storedCharacterExtractionCallCount }+    }+    var lastCharacterExtractionLimit: Int? {+        recorderLock.withLock { storedLastCharacterExtractionLimit }+    }+    /// Doubly optional so a test can tell "asked for every work" from "was never+    /// called".+    var lastCharacterExtractionWorkIDs: Set<UUID>?? {+        recorderLock.withLock { storedLastCharacterExtractionWorkIDs }+    }+    /// Every coverage advance, in order, so the produced-none rule is an+    /// assertion about what was written rather than about what was not.+    var advancedCoverage: [(workID: UUID, sources: [CharacterCompletedSource])] {+        recorderLock.withLock { storedAdvancedCoverage }+    }+    var committedCharacterDecisions: [CharacterDecisionRequest] {+        recorderLock.withLock { storedCharacterDecisions }+    }+    var committedCharacterEdits: [(workID: UUID, operations: [CharacterEditOperation])] {+        recorderLock.withLock { storedCharacterEdits }+    }++    private var storedCharacterExtractionCallCount = 0+    private var storedLastCharacterExtractionLimit: Int?+    private var storedLastCharacterExtractionWorkIDs: Set<UUID>??+    private var storedAdvancedCoverage: [(workID: UUID, sources: [CharacterCompletedSource])] = []+    private var storedCharacterDecisions: [CharacterDecisionRequest] = []+    private var storedCharacterEdits: [(workID: UUID, operations: [CharacterEditOperation])] = []++    func characterExtractionCandidates(+        limit: Int, workIDs: Set<UUID>?+    ) async throws -> [CharacterExtractionCandidate] {+        recorderLock.withLock {+            storedCharacterExtractionCallCount += 1+            storedLastCharacterExtractionLimit = limit+            storedLastCharacterExtractionWorkIDs = workIDs+            callLog.append("characterExtractionCandidates")+        }+        let rows = try characterExtractionCandidatesResult.get()+        let selected = workIDs.map { ids in rows.filter { ids.contains($0.workID) } } ?? rows+        // The repository orders newest activity first and then caps; the double+        // does the same, so a test asserting the sweep's order is asserting the+        // sweep's order and not the fixture's.+        return Array(+            selected.sorted {+                $0.recency == $1.recency+                    ? $0.workID.uuidString < $1.workID.uuidString+                    : $0.recency > $1.recency+            }+            .prefix(limit))+    }++    @discardableResult+    func advanceCharacterCoverage(+        workID: UUID, sources: [CharacterCompletedSource]+    ) async throws -> Int {+        recorderLock.withLock {+            storedAdvancedCoverage.append((workID: workID, sources: sources))+            callLog.append("advanceCharacterCoverage")+        }+        return try advanceCharacterCoverageResult.get()+    }++    func commitCharacterDecision(+        _ request: CharacterDecisionRequest+    ) async throws -> CharacterDecisionOutcome {+        recorderLock.withLock {+            storedCharacterDecisions.append(request)+            callLog.append("commitCharacterDecision")+        }+        return try commitCharacterDecisionResult.get()+    }++    func commitCharacterEdits(+        workID: UUID, operations: [CharacterEditOperation]+    ) async throws -> CharacterEditOutcome {+        recorderLock.withLock {+            storedCharacterEdits.append((workID: workID, operations: operations))+            callLog.append("commitCharacterEdits")+        }+        return try commitCharacterEditsResult.get()+    }+     // MARK: - Work types      var workTypesCallCount = 0
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Modified +5 / -3
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex 1f388af..fa47a7f 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -232,9 +232,11 @@ struct IntegrationSafetyNetTests {                     Issue.record("Expected Backup export for \(environment) \(capabilities.gate.rawValue)")                     continue                 }-                // Settings writes 5/6 now (`configurable-work-types` Req 7.6);-                // the gate the file declares is still the running one.-                let document = try BackupV5Codec.decode(Data(contentsOf: backupURL))+                // Settings writes 6/7 now (`character-extraction` Req 6.1): the+                // archive has to carry characters, suppressions and coverage, so+                // the round-trip is reachable from the surface the reader uses.+                // The gate the file declares is still the running one.+                let document = try BackupV6Codec.decode(Data(contentsOf: backupURL))                 #expect(document.capabilityGate == AsterismCapabilities.current.gate.rawValue)                 backup.handleShareCancellation()             }
Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift Modified +4 / -1
diff --git a/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swiftindex 59827cc..ee6f395 100644--- a/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift+++ b/Asterism/AsterismTests/RuleSuggestionCoordinatorTests.swift@@ -157,8 +157,11 @@ struct RuleSuggestionCoordinatorTests {         let suggester = StubSuggester()         let environment = StubEnvironment()         let client = MutableAvailabilityClient(availability)+        // A lane of its own: `ModelLane.shared` is app-wide by design, and a+        // suite sharing one slot across cases would couple them.         let coordinator = RuleSuggestionCoordinator(-            library: mock, model: client, suggester: suggester, environment: environment)+            library: mock, model: client, suggester: suggester, environment: environment,+            lane: ModelLane())         return (coordinator, mock, suggester, environment, client)     } 
Asterism/AsterismTests/SettingsBackupModelTests.swift Modified +83 / -6
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex a46f52f..4af35d7 100644--- a/Asterism/AsterismTests/SettingsBackupModelTests.swift+++ b/Asterism/AsterismTests/SettingsBackupModelTests.swift@@ -213,7 +213,7 @@ struct SettingsBackupModelTests {     @MainActor func tornGroupsMessageStatesTheCount() async {         let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV5ExportError.tornGroups(+            BackupV6ExportError.tornGroups(                 TornGroupsPayload(count: 3, blockingWorkSet: nil)))          let model = SettingsBackupModel(exporter: mock)@@ -234,7 +234,7 @@ struct SettingsBackupModelTests {     @MainActor func tornGroupsMessageReadsSingular() async {         let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV5ExportError.tornGroups(+            BackupV6ExportError.tornGroups(                 TornGroupsPayload(count: 1, blockingWorkSet: nil)))          let model = SettingsBackupModel(exporter: mock)@@ -252,7 +252,7 @@ struct SettingsBackupModelTests {     @MainActor func tornGroupsMessagePointsAtTheBlockingWorkSet() async {         let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV5ExportError.tornGroups(+            BackupV6ExportError.tornGroups(                 TornGroupsPayload(                     count: 1,                     blockingWorkSet: DuplicateSetKey(@@ -288,7 +288,7 @@ struct SettingsBackupModelTests {          let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV5ExportError.tornGroups(+            BackupV6ExportError.tornGroups(                 TornGroupsPayload(count: 1, blockingWorkSet: nil)))         let model = SettingsBackupModel(exporter: mock)         await model.startExport()@@ -302,6 +302,83 @@ struct SettingsBackupModelTests {         #expect(!model.routesToCheckLibrary)     } +    // MARK: - Archive generation 6/7 (character-extraction Req 6.1)++    /// The Settings surface is the only place the app *writes* an archive, so a+    /// repository that reaches 6/7 while this seam still asks for 5/6 leaves the+    /// round-trip Req 6.1 promises unreachable. The metadata type is the tell:+    /// the exporter this model holds is the one whose payload carries+    /// characters, suppressions and coverage.+    @Test("The export surface asks the 6/7 exporter for the archive")+    @MainActor func exportsArchiveGenerationSixSeven() async {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }+        let fakeURL = tempDir.appending(path: "Asterism-backup-67.json")+        try? Data("{}".utf8).write(to: fakeURL)++        let mock = MockBackupExporting()+        mock.exportResult = .success(BackupExportResult(fileURL: fakeURL))++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        let metadata: BackupV6Metadata? = mock.lastMetadata+        #expect(metadata != nil)+        #expect(metadata?.appBuild.isEmpty == false)+    }++    /// Req 6.5, through Q105: a torn **character** group refuses the export the+    /// same way a torn Work or Entry does, and the reader is sent to the same+    /// place. The payload carries a count, not a record kind, so what this pins+    /// is that the 6/7 refusal reaches a message arm at all — an unhandled case+    /// would fall through to the generic "please try again", which is the dead+    /// end Decision 20 already removed once.+    @Test("A 6/7 torn refusal routes the reader to Check Library")+    @MainActor func sixSevenTornRefusalRoutes() async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(+            BackupV6ExportError.tornGroups(+                TornGroupsPayload(count: 2, blockingWorkSet: nil)))++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        let message = model.errorMessage ?? ""+        #expect(message.contains("2 records"))+        #expect(message.contains("Check Library"))+        #expect(!message.contains("Please try again"))+        #expect(model.routesToCheckLibrary)+    }++    /// Every case of the 6/7 refusal has a message of its own. A case that fell+    /// through to the default arm would be indistinguishable from an error the+    /// app has never heard of.+    @Test("Every 6/7 export refusal has its own message", arguments: [+        BackupV6ExportError.referencesStillArriving(detail: "rule 1"),+        BackupV6ExportError.unrepresentableValue(+            record: "Character", field: "factsData", value: "…"),+        BackupV6ExportError.snapshotFailed(reason: "read"),+        BackupV6ExportError.encodingFailed(reason: "encode"),+        BackupV6ExportError.stagingFailed(reason: "stage"),+    ])+    @MainActor func everySixSevenRefusalHasAMessage(error: BackupV6ExportError) async {+        let mock = MockBackupExporting()+        mock.exportResult = .failure(error)++        let model = SettingsBackupModel(exporter: mock)+        await model.startExport()++        let message = model.errorMessage ?? ""+        #expect(!message.isEmpty)+        // Privacy holds by construction: the reason strings above never reach+        // the reader.+        #expect(!message.contains("rule 1"))+        #expect(!message.contains("factsData"))+        // Only a torn refusal has somewhere to go.+        #expect(!model.routesToCheckLibrary)+    }+     // MARK: - Scavenging on Init      @Test("Scavenges stale files on initialization")@@ -320,12 +397,12 @@ final class MockBackupExporting: BackupExporting, @unchecked Sendable {     var cleanupCallCount = 0     var scavengeCallCount = 0     var lastCleanupURL: URL?-    var lastMetadata: BackupV5Metadata?+    var lastMetadata: BackupV6Metadata?      var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured)     var exportDelay: Duration? -    func export(metadata: BackupV5Metadata) async throws -> BackupExportResult {+    func export(metadata: BackupV6Metadata) async throws -> BackupExportResult {         exportCallCount += 1         lastMetadata = metadata         if let delay = exportDelay {
Asterism/AsterismTests/WorkDetailCharacterTests.swift Added +661 / -0
diff --git a/Asterism/AsterismTests/WorkDetailCharacterTests.swift b/Asterism/AsterismTests/WorkDetailCharacterTests.swiftnew file mode 100644index 0000000..287593e--- /dev/null+++ b/Asterism/AsterismTests/WorkDetailCharacterTests.swift@@ -0,0 +1,661 @@+import AsterismCore+import AsterismIntelligence+import Foundation+import SwiftData+import Testing+@testable import Asterism++/// The work page's character half (Reqs 3.2, 3.7, 5.1–5.4, 6.5), the entry+/// detail's citing-characters section, and the duplicate-resolution sheet's+/// character arm.+///+/// The repository calls these drive are covered against a real store in+/// `CharacterEditingTests`. What is pinned here is the edit session: what is+/// staged, when it is captured, what a discard throws away, and what the one+/// commit call is given.+@Suite("Work page characters")+@MainActor+struct WorkDetailCharacterTests {++    // MARK: - Fixtures++    private nonisolated static func fact(+        _ statement: String, quote: String, source: SourceRef = .genericNotes+    ) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: "ada", source: source)+    }++    private nonisolated static func character(+        id: UUID = UUID(), name: String = "Ada", note: String = "",+        aliases: [String] = [], facts: [CharacterFact] = [], isTorn: Bool = false+    ) -> WorkCharacterPresentation {+        WorkCharacterPresentation(+            id: id, name: name, note: note, aliases: aliases,+            nameKey: CharacterNameKey.normalize(name),+            facts: facts.map {+                WorkCharacterFactRow(+                    id: $0.quote, statement: $0.statement, quote: $0.quote, source: $0.source,+                    citedEntryID: nil, citationTitle: nil, isDangling: false, fact: $0)+            },+            isTorn: isTorn, rowCount: isTorn ? 2 : 1,+            editBasis: CharacterEditBasis(+                characterID: id, name: name, note: note, aliases: aliases, facts: facts))+    }++    private nonisolated static func presentation(+        workID: UUID, characters: [WorkCharacterPresentation]+    ) -> WorkDetailPresentation {+        let base = TestFixtures.makeWorkDetail(work: TestFixtures.makeWork(id: workID))+        return WorkDetailPresentation(+            work: base.work,+            pulse: base.pulse,+            lastNotedURLString: base.lastNotedURLString,+            chapterRows: base.chapterRows,+            characters: characters)+    }++    private func makeSUT(+        workID: UUID = UUID(),+        characters: [WorkCharacterPresentation] = []+    ) -> (WorkDetailModel, MockLibraryProvider) {+        let mock = MockLibraryProvider()+        mock.workDetailResult = .success(Self.presentation(workID: workID, characters: characters))+        let model = WorkDetailModel(workID: workID, library: mock, onMutation: {})+        return (model, mock)+    }++    // MARK: - Display (Reqs 5.1, 5.2)++    @Test("A work with no characters shows nothing character-related")+    func noCharactersShowsNothing() async {+        let (model, _) = makeSUT()++        await model.load()++        #expect(model.characters.isEmpty)+    }++    @Test("The page shows the work's characters as the repository ordered them")+    func showsCharactersInRepositoryOrder() async {+        let (model, _) = makeSUT(+            characters: [Self.character(name: "Ada"), Self.character(name: "Brede")])++        await model.load()++        #expect(model.characters.map(\.name) == ["Ada", "Brede"])+    }++    // MARK: - Drafts (Req 3.2, Q97)++    @Test("Character drafts are captured at load, before any save reloads the screen")+    func draftsCapturedAtLoad() async {+        let id = UUID()+        let (model, _) = makeSUT(characters: [Self.character(id: id, name: "Ada", note: "Keeper")])++        await model.load()++        let draft = model.characterDraft(for: id)+        #expect(draft?.name == "Ada")+        #expect(draft?.note == "Keeper")+    }++    @Test("Editing a draft stages an update against the basis the editor opened on")+    func editingStagesAnUpdate() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Ada")])+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        await model.commitEditing()++        let step = mock.committedCharacterEdits.first+        #expect(step?.operations.count == 1)+        guard case .update(let basis, let draft)? = step?.operations.first else {+            Issue.record("Expected an update operation")+            return+        }+        #expect(basis.name == "Ada")+        #expect(draft.name == "Ada Vance")+    }++    @Test("An untouched character stages nothing")+    func untouchedCharacterStagesNothing() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        await model.commitEditing()++        #expect(mock.committedCharacterEdits.isEmpty)+    }++    @Test("Creating a character stages a create")+    func creatingStagesACreate() async {+        let (model, mock) = makeSUT()+        await model.load()+        model.beginEditing()++        model.addCharacter(named: "Brede")+        await model.commitEditing()++        guard case .create(let draft)? = mock.committedCharacterEdits.first?.operations.first else {+            Issue.record("Expected a create operation")+            return+        }+        #expect(draft.name == "Brede")+    }++    @Test("Deleting a character stages a delete and removes it from the drafts")+    func deletingStagesADelete() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        model.deleteCharacter(id: id)+        #expect(model.characterDrafts.isEmpty)++        await model.commitEditing()++        guard case .delete(let basis)? = mock.committedCharacterEdits.first?.operations.first else {+            Issue.record("Expected a delete operation")+            return+        }+        #expect(basis.characterID == id)+    }++    @Test("Deleting a fact from a draft is a deletion the commit carries")+    func deletingAFactStagesIt() async {+        let id = UUID()+        let kept = Self.fact("Ada keeps the light.", quote: "keeps the light")+        let dropped = Self.fact("Ada rows out.", quote: "rows out")+        let (model, mock) = makeSUT(+            characters: [Self.character(id: id, facts: [kept, dropped])])+        await model.load()+        model.beginEditing()++        model.deleteFact(dropped.identity, from: id)+        await model.commitEditing()++        guard case .update(_, let draft)? = mock.committedCharacterEdits.first?.operations.first+        else {+            Issue.record("Expected an update operation")+            return+        }+        #expect(draft.facts.map(\.quote) == ["keeps the light"])+    }++    // MARK: - Combine (Req 3.7, Q97)++    @Test("Combining is staged, in the order performed, and discardable until commit")+    func combineIsStagedAndDiscardable() async {+        let source = UUID(), target = UUID()+        let (model, mock) = makeSUT(+            characters: [+                Self.character(id: source, name: "Ada"),+                Self.character(id: target, name: "Ada Vance"),+            ])+        await model.load()+        model.beginEditing()++        model.combineCharacter(source: source, into: target)+        // The source is gone from the page as soon as it is staged, so the+        // reader sees what they asked for before it lands.+        #expect(model.characterDrafts[source] == nil)++        model.cancelEditing()++        #expect(mock.committedCharacterEdits.isEmpty)+        #expect(model.characterDrafts[source] != nil)+    }++    @Test("A combine followed by an edit of the target commits in that order")+    func stagedOperationsKeepTheirOrder() async {+        let source = UUID(), target = UUID()+        let (model, mock) = makeSUT(+            characters: [+                Self.character(id: source, name: "Ada"),+                Self.character(id: target, name: "Ada Vance"),+            ])+        await model.load()+        model.beginEditing()++        model.combineCharacter(source: source, into: target)+        model.updateCharacterDraft(id: target) { $0.note = "One person after all." }+        await model.commitEditing()++        let operations = mock.committedCharacterEdits.first?.operations ?? []+        #expect(operations.count == 2)+        guard case .combine = operations.first else {+            Issue.record("Expected the combine first")+            return+        }+        guard case .update = operations.last else {+            Issue.record("Expected the update last")+            return+        }+    }++    @Test("A torn character cannot be combined or edited")+    func tornCharacterIsReadOnly() async {+        let torn = UUID(), other = UUID()+        let (model, _) = makeSUT(+            characters: [+                Self.character(id: torn, name: "Ada", isTorn: true),+                Self.character(id: other, name: "Brede"),+            ])+        await model.load()+        model.beginEditing()++        #expect(!model.canEditCharacter(id: torn))+        #expect(model.canEditCharacter(id: other))+        #expect(model.combineTargets(for: other).map(\.id) == [])+    }++    // MARK: - Commit (Req 5.3, Q104)++    @Test("A refused character step keeps the editor open and names the character")+    func refusedStepKeepsTheEditorOpen() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Ada")])+        mock.commitCharacterEditsResult = .success(+            .refused(.basisMismatch(characterID: id, name: "Ada")))+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        await model.commitEditing()++        #expect(model.isEditing)+        #expect(model.errorMessage?.contains("Ada") == true)+    }++    /// Q104: a tear can sync in while the editor sits open, so `commitCharacterEdits`+    /// re-verifies the *work's* tornness inside the transaction. The UI has to+    /// render that refusal — before this it was a case the sheet could reach and+    /// not describe.+    @Test("A work torn under the open editor refuses the whole step and says so")+    func workTornRefusalIsRendered() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        mock.commitCharacterEditsResult = .success(.refused(.workTorn))+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        await model.commitEditing()++        #expect(model.isEditing)+        #expect(model.errorMessage?.contains("copies") == true)+    }++    @Test("A torn character refusal routes the reader to the resolution surface")+    func tornCharacterRefusalRoutes() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Ada")])+        mock.commitCharacterEditsResult = .success(+            .refused(.torn(characterID: id, name: "Ada")))+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        await model.commitEditing()++        #expect(model.isEditing)+        #expect(model.characterRefusalRoutesToCheckLibrary)+    }++    @Test("A committed character step leaves the editor and reloads")+    func committedStepLeavesTheEditor() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        await model.commitEditing()++        #expect(!model.isEditing)+        #expect(mock.committedCharacterEdits.count == 1)+    }++    @Test("The character step runs even when no metadata changed")+    func characterStepRunsWithoutMetadataChanges() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        await model.commitEditing()++        #expect(mock.committedCharacterEdits.count == 1)+        #expect(mock.updateWorkCallCount == 0)+    }++    /// The `.conflict` path already preserved the drafts, on the grounds that a+    /// draft is the only copy of itself until it lands. The `throw` path+    /// restored from the snapshot instead, which re-adopted the stored+    /// characters and so discarded the whole staged session — silently, while+    /// the editor stayed open on what looked like the reader's own work.+    @Test("A thrown metadata save keeps the staged character session")+    func thrownSaveKeepsTheStagedSession() async {+        let existing = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: existing, name: "Ada")])+        await model.load()+        model.beginEditing()++        model.updateCharacterDraft(id: existing) { $0.note = "Keeper" }+        let created = model.addCharacter(named: "Brede")+        model.draftTitle = "A corrected title"+        mock.updateWorkResult = .failure(MockLibraryProvider.MockError.notConfigured)++        await model.commitEditing()++        #expect(model.errorMessage != nil)+        #expect(model.isEditing, "a failed save keeps the editor open")+        #expect(model.draftTitle == "A corrected title")+        #expect(model.characterDraft(for: existing)?.note == "Keeper")+        #expect(model.createdCharacterIDs == [created])+        #expect(mock.committedCharacterEdits.isEmpty, "nothing was written")+    }++    /// The screen used to sort the draft dictionary's keys by `uuidString`,+    /// which is a random order: each new row landed in an arbitrary place among+    /// the ones already added, and adding one re-ordered the rest.+    @Test("Created characters keep the order the reader added them in")+    func createdCharactersKeepInsertionOrder() async {+        let (model, _) = makeSUT()+        await model.load()+        model.beginEditing()++        let first = model.addCharacter(named: "Ada")+        let second = model.addCharacter(named: "Brede")+        let third = model.addCharacter(named: "Cass")++        #expect(model.createdCharacterIDs == [first, second, third])++        model.deleteCharacter(id: second)+        #expect(model.createdCharacterIDs == [first, third])+    }+}++// MARK: - Entry detail (Req 5.4)++@Suite("Entry detail citing characters")+@MainActor+struct EntryDetailCitingCharactersTests {++    private nonisolated static func detail(+        entryID: UUID, citing: [EntryCitingCharacter]+    ) -> EntryTeachingDetail {+        EntryTeachingDetail(+            entry: TestFixtures.makeEntry(id: entryID),+            siteMode: .untaught,+            activePatternSummary: nil,+            historicalPatternSummaries: [],+            chapterSettlement: .unsettled(reason: "no pattern"),+            assignmentSettlement: .unsettled(reason: "no pattern"),+            availableActions: [],+            unresolvedCandidateTitle: nil,+            citingCharacters: citing)+    }++    @Test("An entry no character cites shows no section")+    func noCitationsShowsNothing() async {+        let mock = MockLibraryProvider()+        let entryID = UUID()+        mock.entryTeachingDetailResult = .success(Self.detail(entryID: entryID, citing: []))+        let model = EntryDetailModel(entryID: entryID, library: mock, onMutation: {})++        await model.load()++        #expect(model.citingCharacters.isEmpty)+    }++    @Test("An entry a character cites names them and how many facts")+    func citationsAreNamed() async {+        let mock = MockLibraryProvider()+        let entryID = UUID()+        mock.entryTeachingDetailResult = .success(+            Self.detail(+                entryID: entryID,+                citing: [EntryCitingCharacter(id: UUID(), name: "Ada", factCount: 2)]))+        let model = EntryDetailModel(entryID: entryID, library: mock, onMutation: {})++        await model.load()++        #expect(model.citingCharacters.map(\.name) == ["Ada"])+        #expect(model.citingCharacters.first?.factCount == 2)+    }+}++// MARK: - The duplicate-resolution character arm (Req 6.5, Q102)++@Suite("Duplicate resolution character arm")+@MainActor+struct DuplicateResolutionCharacterArmTests {++    private nonisolated static func contract(+        setKey: DuplicateSetKey, variants: [CharacterVariantChoice]+    ) -> DuplicateResolutionContract {+        .character(+            setKey: setKey, variants: variants, differingFields: [.note],+            preselected: variants[0].id)+    }++    @Test("A character set's variants reach the sheet")+    func characterVariantsAreExposed() async {+        let id = UUID()+        let setKey = DuplicateSetKey(recordType: .character, memberIDs: [id])+        let variants = [+            CharacterVariantChoice(+                id: VariantID(rawValue: "a"), name: "Ada", note: "Keeper", aliases: [],+                factCount: 3, firstCapturedAt: Date(timeIntervalSince1970: 0)),+            CharacterVariantChoice(+                id: VariantID(rawValue: "b"), name: "Ada", note: "Lightkeeper",+                aliases: ["Nightjar"], factCount: 2,+                firstCapturedAt: Date(timeIntervalSince1970: 10)),+        ]+        let mock = MockLibraryProvider()+        mock.projectDuplicateResolutionResult = .success(+            Self.contract(setKey: setKey, variants: variants))+        let model = DuplicateResolutionModel(setKey: setKey, library: mock, onMutation: {})++        await model.load()++        #expect(model.characterVariants.map(\.note) == ["Keeper", "Lightkeeper"])+        #expect(model.entryVariants.isEmpty)+        #expect(model.workVariants.isEmpty)+        #expect(model.selectedVariantID == VariantID(rawValue: "a"))+    }++    private nonisolated static func tornCharacterItem() -> DuplicateReviewItem {+        let id = UUID()+        return DuplicateReviewItem(+            key: DuplicateSetKey(recordType: .character, memberIDs: [id]),+            route: .sheet,+            memberIDs: [id],+            variantCount: 2,+            isTorn: true)+    }++    /// `MaintenanceViewModels`' work-else-entry ternary called every non-work+    /// set an entry. A torn character listed as "one entry" sends the reader+    /// looking for a note that does not exist.+    @Test("A character set is labelled as a character, not as an entry")+    func characterSetIsLabelledCorrectly() async {+        let mock = MockLibraryProvider()+        mock.duplicateWorkload = (+            DuplicateWorkload(reviewItems: [Self.tornCharacterItem()], deferredItems: []))+        let model = LibraryDiagnosticsModel(library: mock, onReteach: { _ in })++        await model.load()++        let text = model.rows.map(\.problem).joined(separator: " ")+        #expect(text.contains("character"))+        #expect(!text.contains("entry"))+    }++    /// `RecentView`'s recordType filter kept only `.entry` items as rows and+    /// swept the rest into "elsewhere" — which is right for a character, but+    /// only because the arm names it. Without one the line reads as a bare+    /// count.+    @Test("A character set is not mis-bucketed into Recent's entry filter")+    func characterSetIsNotAnEntryItem() {+        let plan = RecentDuplicatePlan(+            workload: DuplicateWorkload(reviewItems: [Self.tornCharacterItem()], deferredItems: []),+            conflictCount: 0)++        #expect(plan.entryItems.isEmpty)+        #expect(plan.elsewhere.count == 1)+        #expect(plan.elsewhere.first?.text.contains("character") == true)+    }+}++// MARK: - The edit step against a real store (Q97, Q108)++/// The combine-then-edit journey through the **real** seam: the shipping+/// `WorkDetailModel` deriving its operations from a real edit session, and the+/// shipping `LibraryRepository` committing them against a real store.+///+/// A double on either side hides the bug this suite exists for. The repository+/// suite's own order test hand-builds the trailing update's basis from the+/// *post-combine* state, which no editor can produce: the model sends the+/// **load-time** basis, so every derived update after a combine mismatched and+/// the whole step refused as "changed elsewhere" — naming a concurrent editor+/// who did not exist. Q108 answers it: within one step a character's basis is+/// verified on first touch only.+@Suite("The character edit step through the work page", .serialized)+@MainActor+struct WorkDetailCharacterCommitTests {++    private struct Fixture {+        let directory: URL+        /// Retained for the test's lifetime: a `ModelContext` does not keep its+        /// container alive, and a temporary one deallocates under the test.+        let container: ModelContainer+        let repository: LibraryRepository++        init() throws {+            directory = FileManager.default.temporaryDirectory+                .appending(+                    path: "AsterismWorkDetailCharacters-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(+                at: directory, withIntermediateDirectories: true)+            let configuration = LibraryConfiguration(rootDirectory: directory)+            try FileManager.default.createDirectory(+                at: configuration.storeURL.deletingLastPathComponent(),+                withIntermediateDirectories: true)+            container = try LibraryRepository.openContainer(at: configuration.storeURL)+            repository = LibraryRepository.makeRepository(+                configuration, container, .m4, FixedCommitClock(), ModelContextSaveStrategy())+        }++        func cleanup() { try? FileManager.default.removeItem(at: directory) }+    }++    private nonisolated static func fact(+        _ statement: String, _ quote: String, key: String+    ) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: key, source: .genericNotes)+    }++    /// One work with two characters, each carrying a fact of its own.+    private static func seed(+        _ fixture: Fixture+    ) async throws -> (workID: UUID, keeper: UUID, rower: UUID) {+        let work = try await fixture.repository.createWork(+            NewWorkDraft(displayTitle: "The Lamp Room", hostname: "characters.test"))+        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: work.id,+            operations: [+                .create(CharacterDraft(+                    name: "Ada", note: "the keeper", aliases: [],+                    facts: [Self.fact("Ada keeps the light.", "Ada keeps the light", key: "ada")])),+                .create(CharacterDraft(+                    name: "Vance", note: "the rower", aliases: [],+                    facts: [Self.fact("Vance rows out.", "Vance rows out", key: "vance")])),+            ])+        guard case .committed(let ids) = outcome, ids.count == 2 else {+            throw CommitFixtureError.seedRefused(String(describing: outcome))+        }+        return (work.id, ids[0], ids[1])+    }++    @Test("A combine and an edit of the combined character commit together, in order")+    func combineThenEditCommits() async throws {+        let fixture = try Fixture()+        defer { fixture.cleanup() }+        let (workID, keeper, rower) = try await Self.seed(fixture)++        let model = WorkDetailModel(workID: workID, library: fixture.repository, onMutation: {})+        await model.load()+        #expect(model.characters.count == 2)+        model.beginEditing()++        model.combineCharacter(source: rower, into: keeper)+        model.updateCharacterDraft(id: keeper) {+            $0.name = "Adelaide"+            $0.note = "One person after all."+        }+        await model.commitEditing()++        #expect(model.errorMessage == nil, "Q108: the step's own writes are not a stale basis")+        #expect(!model.isEditing)++        let after = model.characters+        #expect(after.count == 1)+        #expect(after.first?.name == "Adelaide", "the edit landed on top of the combine")+        #expect(after.first?.note == "One person after all.")+        // The combine's own writes survive the update the editor derives from a+        // load-time draft: the absorbed name still routes, and the moved fact is+        // still here rather than deleted as an omission.+        #expect(after.first?.aliases.contains("Vance") == true)+        #expect(+            Set(after.first?.facts.map(\.statement) ?? [])+                == ["Ada keeps the light.", "Vance rows out."])+        withExtendedLifetime(fixture) {}+    }++    @Test("A character changed elsewhere still refuses the whole step on first touch")+    func externalChangeStillRefusesTheStep() async throws {+        let fixture = try Fixture()+        defer { fixture.cleanup() }+        let (workID, keeper, _) = try await Self.seed(fixture)++        let model = WorkDetailModel(workID: workID, library: fixture.repository, onMutation: {})+        await model.load()+        let basis = try #require(model.characters.first { $0.id == keeper }?.editBasis)+        model.beginEditing()+        model.updateCharacterDraft(id: keeper) { $0.note = "Mine." }++        // Another device writes to the same character while the editor sits open.+        _ = try await fixture.repository.commitCharacterEdits(+            workID: workID,+            operations: [.update(+                basis: basis,+                draft: CharacterDraft(+                    name: "Ada", note: "Theirs.", aliases: [],+                    facts: [+                        Self.fact("Ada keeps the light.", "Ada keeps the light", key: "ada")+                    ]))])++        await model.commitEditing()++        #expect(model.isEditing, "a refused step keeps the editor open")+        #expect(model.errorMessage?.contains("changed elsewhere") == true)+        withExtendedLifetime(fixture) {}+    }+}++private enum CommitFixtureError: Error {+    case seedRefused(String)+}++private final class FixedCommitClock: RepositoryClock, @unchecked Sendable {+    private let value = Date(timeIntervalSince1970: 1_800_000_000)+    func now() -> Date { MillisecondInstant.quantize(value) }+}
Asterism/AsterismTests/WorkDetailModelTests.swift Modified +12 / -7
diff --git a/Asterism/AsterismTests/WorkDetailModelTests.swift b/Asterism/AsterismTests/WorkDetailModelTests.swiftindex d54ac7d..76f59ac 100644--- a/Asterism/AsterismTests/WorkDetailModelTests.swift+++ b/Asterism/AsterismTests/WorkDetailModelTests.swift@@ -65,8 +65,13 @@ struct WorkDetailModelTests {         #expect(tracker.mutationCount == 1)     } -    @Test("Save failure restores drafts from prior snapshot")-    @MainActor func saveFailureRestores() async {+    /// The `.conflict` path already kept the drafts, on the grounds that a draft+    /// is the only copy of itself until it lands and the editor is still open on+    /// it. A throw is the same situation — nothing was written — so it now keeps+    /// them too. Restoring here also re-adopted the stored characters, which+    /// silently discarded the session's whole staged character step.+    @Test("A failed save keeps the drafts, exactly as a refused one does")+    @MainActor func saveFailureKeepsDrafts() async {         let work = TestFixtures.makeWork(             displayTitle: "Prior Title",             typeDisplay: WorkTypeDirectory.empty.display(of: .legacy("novel")),@@ -83,11 +88,11 @@ struct WorkDetailModelTests {         model.draftTags = ["new-tag"]         model.draftNotes = "changed notes"         await model.save()-        // Restored from snapshot-        #expect(model.draftTitle == "Prior Title")-        #expect(model.draftAssignment == .legacy("novel"))-        #expect(model.draftTags == ["drama"])-        #expect(model.draftNotes == "prior notes")+        // Kept, not restored: nothing was written, and the editor still has them.+        #expect(model.draftTitle == "Changed")+        #expect(model.draftAssignment == .legacy("toon"))+        #expect(model.draftTags == ["new-tag"])+        #expect(model.draftNotes == "changed notes")         #expect(model.errorMessage != nil)     } 
Asterism/AsterismUITests/CharacterExtractionUITests.swift Added +327 / -0
diff --git a/Asterism/AsterismUITests/CharacterExtractionUITests.swift b/Asterism/AsterismUITests/CharacterExtractionUITests.swiftnew file mode 100644index 0000000..0d19e8f--- /dev/null+++ b/Asterism/AsterismUITests/CharacterExtractionUITests.swift@@ -0,0 +1,327 @@+import XCTest++/// The extraction journey end to end (`character-extraction` Reqs 2.1, 2.2,+/// 5.1, 5.4), from the activation sweep's indicator to a kept character's facts+/// on the work page and its citation on the entry it came from.+///+/// **Stub-driven, always.** `ASTERISM_UI_TEST_EXTRACTION=canned` substitutes a+/// scripted model client, so nothing here depends on the host having a model or+/// on the model repeating itself — which is what Req 1.7 says reader-visible+/// behaviour may never depend on. Everything downstream of the scripted answer+/// is the shipping code: grounding, the slash split, assembly, matching, and+/// every decision the reader makes.+///+/// The fixture (`seeded-characters`) is one work, "The Lamp Room", whose generic+/// notes and single chapter note name Ada — called Nightjar by the crew — and+/// Brede.+final class CharacterExtractionUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        XCUIDevice.shared.orientation = .portrait+        terminateAndWaitForExit(app)+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-characters"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+        app.launchEnvironment["ASTERISM_UI_TEST_EXTRACTION"] = "canned"+        app.launch()+    }++    override func tearDown() {+        terminateAndWaitForExit(app)+    }++    // MARK: - Navigation++    private func openWorkDetail() {+        waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+        waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+        waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+        waitFor(+            app.elements(withIdentifierPrefix: "work-row-").firstMatch,+            "The seeded work is listed").tap()+        waitFor(app.anyElement("work-detail-pulse"), "Work detail presents the rating pulse")+    }++    private func openReview() {+        // The sweep runs on activation and is never awaited by it, so the+        // indicator arrives after the screen does.+        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The sweep's proposals raise the indicator", timeout: 60)+        waitFor(+            app.buttons["work-detail-review-characters-button"],+            "The indicator offers the review list").tap()+        waitFor(app.anyElement("character-review-banner"), "The review list opens")+    }++    private func closeReview() {+        waitFor(app.buttons["character-review-done"], "The review list closes").tap()+        waitUntilGone(+            app.anyElement("character-review-banner"), "The review list is dismissed", timeout: 15)+    }++    /// The proposed-fact toggle whose statement contains `text`.+    ///+    /// Deliberately not addressed by its identifier alone: a fact's id is its+    /// `(source, quote, statement)` identity triple joined by a unit separator+    /// (Q29), which is not a string a journey can type. The identifier prefix+    /// plus the statement the reader can actually see is.+    private func factToggle(containing text: String) -> XCUIElement {+        let facts = app.descendants(matching: .any)+            .matching(NSPredicate(format: "identifier BEGINSWITH %@", "character-review-fact-"))+        let statement = NSPredicate(format: "label CONTAINS[c] %@", text)+        let byOwnLabel = facts.matching(statement).firstMatch+        // SwiftUI publishes a `Toggle` with a composed label either as one+        // combined label or as a switch over its own text elements, and which+        // one it picks depends on the row. Both are tried rather than guessed.+        return byOwnLabel.exists ? byOwnLabel : facts.containing(statement).firstMatch+    }++    /// Flips a `Toggle` row, and asserts that it flipped.+    ///+    /// **Not `element.tap()`.** That taps the centre of the row, which in a+    /// SwiftUI `Form` lands on the label and does nothing at all — the value+    /// comes back unchanged and the journey carries on asserting over a tick it+    /// never removed. The control sits at the trailing edge, so that is where+    /// the tap goes, and the wait on the value is what makes the gesture a fact+    /// rather than a hope.+    private func flip(+        _ toggle: XCUIElement, _ message: String,+        file: StaticString = #filePath, line: UInt = #line+    ) {+        let before = (toggle.value as? String) ?? "1"+        toggle.coordinate(withNormalizedOffset: CGVector(dx: 0.92, dy: 0.5)).tap()+        let flipped = expectation(+            for: NSPredicate(format: "value != %@", before), evaluatedWith: toggle)+        XCTAssertEqual(+            XCTWaiter().wait(for: [flipped], timeout: 10), .completed, message,+            file: file, line: line)+    }++    // MARK: - Req 2.1: the indicator and the list++    func testTheSweepRaisesAnIndicatorAndTheListPresentsBothProposals() {+        openWorkDetail()+        openReview()++        waitFor(app.anyElement("character-review-keep-ada"), "Ada is proposed")+        waitFor(app.anyElement("character-review-keep-brede"), "Brede is proposed")+        // Decision 5: the compound name split, with the second half offered as a+        // strikeable alias rather than installed silently (Q92).+        waitFor(app.anyElement("character-review-alias-Nightjar"), "The split alias is shown")+    }++    // MARK: - Req 2.2: keeping, with a fact unticked and an alias struck++    func testKeepingACharacterWritesOnlyWhatWasTicked() {+        openWorkDetail()+        openReview()++        // Strike the proposed alias: a pairing the reader does not accept must+        // not become a match key (Q92).+        flip(+            waitFor(app.anyElement("character-review-alias-Nightjar"), "The alias is offered"),+            "The alias is struck")++        // Untick one of Ada's two proposed facts. "Only what was ticked" is the+        // whole claim of this journey, and it says nothing at all unless+        // something is left unticked.+        flip(+            waitFor(factToggle(containing: "lighthouse"), "Ada's lighthouse fact is offered"),+            "The lighthouse fact is unticked")++        waitFor(app.anyElement("character-review-keep-ada"), "Ada can be kept").tap()+        waitUntilGone(+            app.anyElement("character-review-keep-ada"), "The decided row leaves the list",+            timeout: 15)+        // The other proposal is untouched by that decision.+        waitFor(app.anyElement("character-review-keep-brede"), "Brede is still undecided")++        closeReview()++        // Req 5.1/5.2: what was kept shows on the page, with its facts.+        waitFor(+            app.anyElement("work-detail-character"), "The kept character appears on the page",+            timeout: 30)+        scrollUntilPresent(+            app.staticTexts["Ada is called Nightjar by the crew."], in: app,+            "The ticked fact was written")+        XCTAssertFalse(+            app.staticTexts["Ada keeps the lighthouse."].exists,+            "The unticked fact was not written")+        // Q92: the struck alias was not installed either.+        XCTAssertFalse(+            app.staticTexts["Also Nightjar"].exists,+            "The struck alias did not become a match key")+    }++    // MARK: - Req 5.3, Q109: the review sheet is unreachable in edit mode++    /// The sheet's completion reload rebuilds the page's character drafts, so a+    /// review opened over a staged edit session would silently destroy it. The+    /// indicator is therefore hidden while the editor is open — the gating the+    /// manual-pass trigger already had.+    func testTheProposalsIndicatorIsAbsentInEditMode() {+        openWorkDetail()+        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The sweep's proposals raise the indicator", timeout: 60)++        waitFor(app.buttons["work-detail-edit-button"], "The page offers its editor").tap()+        waitFor(app.anyElement("work-detail-title-field"), "The editor is open")++        waitUntilGone(+            app.anyElement("work-detail-character-proposals"),+            "The indicator is not offered in edit mode", timeout: 15)++        // And it is back the moment the session is left: the proposals were+        // never decided, so they are still owed a decision (Req 2.6).+        waitFor(app.buttons["work-detail-edit-cancel-button"], "The editor offers its X").tap()+        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The indicator returns in view mode", timeout: 20)+    }++    // MARK: - Req 2.2: skipping++    func testSkippingLeavesTheOtherProposalAndWritesNoCharacter() {+        openWorkDetail()+        openReview()++        waitFor(app.anyElement("character-review-skip-brede"), "Brede can be skipped").tap()+        waitUntilGone(+            app.anyElement("character-review-skip-brede"), "The skipped row leaves the list",+            timeout: 15)+        waitFor(app.anyElement("character-review-keep-ada"), "Ada is still undecided")++        closeReview()++        // A skip writes only system records, so nothing appears on the page.+        XCTAssertFalse(+            app.anyElement("work-detail-character").waitForExistence(timeout: 5),+            "Skipping writes no character")+    }++    // MARK: - Req 2.6: undecided proposals survive dismissal++    func testUndecidedProposalsSurviveDismissal() {+        openWorkDetail()+        openReview()+        closeReview()++        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The indicator still stands for the undecided proposals", timeout: 30)+    }++    // MARK: - Req 5.4: the entry the fact cites says so++    func testAKeptCharacterIsNamedOnTheEntryItCites() {+        openWorkDetail()+        openReview()+        waitFor(app.anyElement("character-review-keep-brede"), "Brede can be kept").tap()+        closeReview()++        waitFor(+            app.anyElement("work-detail-character"), "The kept character appears on the page",+            timeout: 30)++        // The characters section sits above Chapter Notes, so on this screen the+        // chapter row is now below the fold. `waitForExistence` does not scroll.+        let chapterRow = app.anyElement("work-detail-entry")+        for _ in 0..<6 where !chapterRow.exists {+            app.swipeUp()+        }+        waitFor(chapterRow, "The work's chapter note is listed").tap()++        // Entry detail's section sits between the actions and the capture+        // details, so it needs a scroll on a short screen.+        if !app.anyElement("entry-detail-citing-character").waitForExistence(timeout: 5) {+            scrollEntryDetail(in: app)+        }+        waitFor(+            app.anyElement("entry-detail-citing-character"),+            "The entry names the character citing it", timeout: 20)+    }++    // MARK: - Req 1.11: the manual pass and its outcomes++    func testTheManualPassReportsSuggestionsReady() {+        openWorkDetail()+        // Decide everything the sweep produced, so the manual pass is the only+        // thing that could put a proposal back.+        openReview()+        waitFor(app.anyElement("character-review-skip-ada"), "Ada can be skipped").tap()+        waitFor(app.anyElement("character-review-skip-brede"), "Brede can be skipped").tap()+        closeReview()++        waitFor(app.buttons["work-detail-extract-characters"], "The manual trigger is offered").tap()+        // Req 1.11: a manual pass gets past the suppression the skips wrote, so+        // it ends with proposals rather than with nothing.+        waitFor(+            app.anyElement("work-detail-extract-ready"),+            "The manual pass reports what it found", timeout: 60)+    }+}++/// The same journey with the two other scripted clients, which are the only way+/// to reach Req 1.11's other two states.+final class CharacterExtractionOutcomeUITests: XCTestCase {+    let app = XCUIApplication()++    override func setUp() {+        continueAfterFailure = false+        XCUIDevice.shared.orientation = .portrait+        terminateAndWaitForExit(app)+        app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = "seeded-characters"+        app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString+    }++    override func tearDown() {+        terminateAndWaitForExit(app)+    }++    private func openWorkDetail() {+        waitFor(app.collectionViews["recent-list"], "The library opens", timeout: 60)+        waitFor(app.tabBars.buttons["Works"], "The Works tab is reachable").tap()+        waitFor(app.collectionViews["works-list"], "Works lists the seeded library")+        waitFor(+            app.elements(withIdentifierPrefix: "work-row-").firstMatch,+            "The seeded work is listed").tap()+        waitFor(app.anyElement("work-detail-pulse"), "Work detail presents the rating pulse")+    }++    /// Req 1.11: a pass that found nothing still ends with something the reader+    /// can see — the same sentence a failure and a refusal produce, because+    /// Req 1.8 forbids the cause reaching them.+    func testAPassThatFoundNothingSaysSo() {+        app.launchEnvironment["ASTERISM_UI_TEST_EXTRACTION"] = "empty"+        app.launch()+        openWorkDetail()++        XCTAssertFalse(+            app.anyElement("work-detail-character-proposals").waitForExistence(timeout: 10),+            "An empty sweep raises no indicator (Req 4.3)")++        waitFor(app.buttons["work-detail-extract-characters"], "The manual trigger is offered").tap()+        waitFor(+            app.anyElement("work-detail-extract-none"),+            "The pass reports that nothing was found", timeout: 60)+    }++    /// Req 1.11: hidden, not disabled. An action that can never do anything is+    /// not an action.+    func testTheManualTriggerIsAbsentWhileTheModelIsAway() {+        app.launchEnvironment["ASTERISM_UI_TEST_EXTRACTION"] = "unavailable"+        app.launch()+        openWorkDetail()++        XCTAssertFalse(+            app.buttons["work-detail-extract-characters"].waitForExistence(timeout: 10),+            "The trigger is not offered while the model is unavailable")+        XCTAssertFalse(+            app.anyElement("work-detail-character-proposals").exists,+            "An unavailable model sweeps nothing")+    }+}
CHANGELOG.md Modified +54 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex 5334801..0a81f6d 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -8,6 +8,54 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **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+  activation, never in Low Power Mode or under thermal pressure, stopping+  per-source the moment the app deactivates) reads a work's notes one source+  per request and proposes characters — every fact a verbatim quote citing the+  note it came from. Nothing is written until you decide: a work page with+  proposals shows an indicator (hidden while you are editing) opening a review+  sheet with per-candidate accept/skip, per-fact ticks, and per-alias strikes;+  skipping suppresses exactly the keys you saw. Accepted characters appear in+  a Characters section on the work page — fully editable, combinable (combine+  then tidy the merged name in the same session works), and deletable — with+  citing characters shown on each entry's detail. A manual "Find characters"+  pass on the work page runs regardless of sweep budgets. Characters sync,+  join the torn/duplicate machinery with their own resolution flow, and+  round-trip through the backup archive: Settings now exports generation 6/7,+  and older archives import unchanged. A post-integration review hardened the+  sweep's scheduling (attempt-aware slot use, per-source stop), the refusal+  refresh paths (re-routed candidates genuinely re-present as bundles), and+  the edit-step basis contract (Q106–Q110). End-to-end stub-driven UI tests+  cover the whole flow; no live model runs in any UI or app test (the package+  suite keeps its one guarded live-call check per model client).++- **Character extraction foundations — store and intelligence (character-extraction,+  T-2229, not yet user-visible).** The groundwork for on-device character+  extraction: schema V7 adds the `Character` and `CharacterSuppression`+  entities plus per-source coverage fingerprints (V6 frozen, plan+  [V5, V6, V7], marker generation "7"); characters join the full torn/duplicate+  machinery with UUID-only sets (silent merge structurally unreachable) and+  collapse repointing; the repository gains the extraction-candidates read,+  decision commits (accept/skip/untick/bundle with suppression LWW), the+  character edit step (create/edit/delete/combine), work merge/delete+  integration, and entry-detail citations; archive generation 6/7 round-trips+  characters, suppressions and coverage, still importing 4/4 and 5/6, with+  pre-feature archives importing cleanly with zero characters. On the+  intelligence side: `ModelLane`, an app-wide single-slot arbiter the+  rule-suggestion pipeline now also uses (interactive asks a background holder+  to yield, never seizes), the guided-generation extraction client (greedy,+  named story characters only), grounding that keeps only verbatim-quoted+  facts, slash-compound splitting into name plus proposed aliases, canonical+  name keys (repeat-until-stable article stripping, Q99), and the extraction+  ledger (held proposals merged per name key per work, budgets that a manual+  pass never charges). A post-integration design review tightened all of this:+  one name-key recipe across modules, shared value types, and a torn re-check+  inside the edit-commit transaction. No UI yet — the review sheet, work-page+  cast, and sweep wiring are the next phase, and nothing runs or is shown+  until they land.+ - **Suggested rules in the composed teaching editor (rule-suggestion,   T-2156).** On devices where Apple Intelligence is available, the app proposes   a title rule and a URL rule for an untaught hostname from its captures, using@@ -30,6 +78,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Upgrading +- **Character extraction moves the library on to schema V7** (V6 was frozen as+  an intermediate step in the same chain). The step is lightweight — new+  entities and fields only, no data rewrite — and runs in the same single+  first open as any earlier pending steps. A backup archive exported after+  this update is generation 6/7, which older builds cannot read; archives+  from before the update import unchanged. - **Your library migrates to schema V5 the first time you open the app after   this update.** A library still on an older schema traverses every intervening   step in that same single open. The migration is crash-safe and needs no action
CLAUDE.md Modified +8 / -5
diff --git a/CLAUDE.md b/CLAUDE.mdindex 5e657b4..4287a6b 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -40,7 +40,7 @@ overwrites state. A restore is irreversible; a container download is not. Use the `Makefile` for everything. Do not hand-roll `xcodebuild` or `swift test` invocations where a target exists. -- `make test-core` — AsterismCore package tests (host, fast, safe). Since `rule-suggestion` the package has a second product, `AsterismIntelligence` (linked by the app and `AsterismTests` only — never the share extension), and its tests include **one live Apple Intelligence call** that degrades to a `withKnownIssue` when the host has no model available. On a host that does have the model, a transient `GenerationError` (rate limited, assets unavailable) is also a known issue — only a response that will not decode into `RuleProposal` fails the target, so the pre-commit bar stays deterministic either way.+- `make test-core` — AsterismCore package tests (host, fast, safe). Since `rule-suggestion` the package has a second product, `AsterismIntelligence` (linked by the app and `AsterismTests` only — never the share extension), and its tests include **two live Apple Intelligence calls** — one per pipeline, decoding into `RuleProposal` and (since `character-extraction`) into `ExtractionResult` — both of which degrade to a `withKnownIssue` when the host has no model available. On a host that does have the model, a transient `GenerationError` (rate limited, assets unavailable) is also a known issue — only a response that will not decode into the expected structure fails the target, so the pre-commit bar stays deterministic either way. - `make test-quick` — unit-test bundle only (simulator) - `make test` / `make test-ui` — full suites (simulator) - `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~20 minutes** (1,213 s measured 2026-08-09, including a 165 s release build): 62% of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0**, with four accepted breaches reported as `withKnownIssue` known issues rather than failures (Req 10.1's settling pass, Req 5.5's three diagnosis re-derivations); `RUNS=3` therefore completes all three runs. See `specs/retire-migration-chain/verification-run.md` for the numbers and `docs/agent-notes/testing.md` for recording a band.@@ -79,13 +79,16 @@ shipped app. Unit tests must use `Development` — `Personal` has no testability and the `Asterism Personal` scheme deliberately excludes the unit-test bundle for that reason. -### Rule-suggestion diagnostics+### On-device model diagnostics  The suggestion pipeline logs every attempt, refusal, drop point and settle (with the model phase in ms) under `subsystem:me.nore.ig.Asterism-category:RuleSuggestion`. Reader content (titles, URLs, proposal text) is-readable in `Development` builds only; reasons and numbers are always readable.-Filter on that in Console.app with the phone selected.+category:RuleSuggestion`. Character extraction logs the same shape under+`category:CharacterExtraction` — both go through one body (`PipelineLog`), so+the split is identical: reader content (titles, URLs, note text, evidence spans,+proposed names) is readable in `Development` builds only; reasons and numbers+are always readable. Filter on either category in Console.app with the phone+selected.  ## Performance measurement 
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swiftindex 6f83c11..3b72a0d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV5.swift@@ -15,8 +15,8 @@ import SwiftData /// to open: `addPersistentStore` fails with `NSCocoaErrorDomain` 134504, /// "Cannot use staged migration with an unknown model version" (measured for the /// V4 snapshot this file is modelled on, Q20 of `retire-migration-chain`). The-/// live classes therefore moved to `AsterismSchemaV6`, and this declaration-/// exists only to give `AsterismV6MigrationPlan` a `from` version.+/// live classes therefore moved on, and this declaration exists only to give+/// `AsterismV7MigrationPlan` the `from` version of its first stage. /// /// The classes are nested here so they can carry the same SwiftData entity names /// ("Entry", "Site", …) as the live V6 classes without a top-level collision:
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift Modified +167 / -33
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swiftindex f8d3afe..e0f2f8d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV6.swift@@ -1,19 +1,37 @@ import Foundation import SwiftData -/// The runtime schema. Its body is `Models.swift`, which opens-/// `extension AsterismSchemaV6`.+/// The frozen `configurable-work-types` schema — the shape every installed+/// library was written by before `character-extraction`, and the `from` version+/// of the V6 → V7 lightweight stage. ///-/// V6 is V5 plus the two additions `configurable-work-types` needs: the-/// `WorkTypeEntity` table holding the configured list, and `Work.workTypeID`,-/// the plain UUID column a Work cites its type by (Decision 8 — no relationship,-/// so no CloudKit inverse and no fan-out). `Work.typeRaw` stays exactly where it-/// was: it is the compatibility surface pre-feature builds read and write-/// (Decision 4), not a column this feature repurposes.+/// V6 is V5 plus the `WorkTypeEntity` table and `Work.workTypeID`. It does+/// **not** carry `Character`, `CharacterSuppression`, or the two extraction+/// fingerprint columns; those are V7's additions. ///-/// Both additions are additive and CloudKit-legal by construction: every-/// property is defaulted or optional, nothing is unique, and `WorkTypeEntity`-/// declares no relationships at all.+/// V6 is frozen for the same reason V5 is: *any* edit to its body makes a+/// V6-recorded store refuse to open with `NSCocoaErrorDomain` 134504, "Cannot+/// use staged migration with an unknown model version". The live classes+/// therefore moved to `AsterismSchemaV7`, and this declaration exists only to+/// give `AsterismV7MigrationPlan` a `from` version — and to let+/// `V6RecordedStoreFixture` seed a genuinely 6.0.0-recorded store in-process.+///+/// The classes are nested so they can carry the same SwiftData entity names+/// ("Entry", "Site", …) as the live V7 classes without a top-level collision:+/// the only top-level references are typealiases, and two *top-level* `@Model`s+/// sharing an entity name crash `ModelContext`+/// (`docs/agent-notes/schema-migration.md`). Nothing reads a V6-shaped object at+/// runtime, so these carry stored columns only — no accessors, no business+/// logic.+///+/// # These snapshots are frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze the value types+/// they store. `SegmentRangeSpec`, `SegmentPositionSpec`, `URLIdentityRule` and+/// `JunkSuffixRule` are live top-level types in `ValueObjects.swift`, shared+/// with the live classes. Editing any of them changes the stored shape of this+/// frozen schema silently — and that is exactly what makes a recorded store+/// refuse to open (134504). public enum AsterismSchemaV6: VersionedSchema {     public static let versionIdentifier = Schema.Version(6, 0, 0) @@ -23,29 +41,145 @@ public enum AsterismSchemaV6: VersionedSchema {     } } -/// The migration plan: `[V5, V6]`, one lightweight stage.-///-/// It was `AsterismV5MigrationPlan` — one schema, no stages — after-/// `retire-migration-chain` collapsed the V3 → V4 → V5 chain. The V5 → V6 step-/// adds a column and a table and changes nothing that exists, so it is exactly-/// what `.lightweight` is for: `ModelContainer.init` runs the conversion, and no-/// data pass accompanies it (`docs/agent-notes/schema-migration.md`'s six-step-/// table).-///-/// `AsterismSchemaV5` survives as a frozen snapshot rather than being deleted-/// with the live classes: a staged migration needs its `from` version to be a-/// declared schema whose stored shape matches what installed stores actually-/// hold.-///-/// The stage is **not** `.custom`: a custom stage never fires between-/// structurally identical models, and it would also run inside the share-/// extension, which must never migrate.-public enum AsterismV6MigrationPlan: SchemaMigrationPlan {-    public static var schemas: [any VersionedSchema.Type] {-        [AsterismSchemaV5.self, AsterismSchemaV6.self]+extension AsterismSchemaV6 {+    @Model+    public final class Entry {+        public var id: UUID = UUID()+        public var captureTitle: String = ""+        public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue+        public var rawURLString: String = ""+        public var canonicalURLString: String?+        public var hostname: String = ""+        public var site: Site?+        public var entryIdentityKey: String = ""+        public var identityKeyVersion: Int = 1+        public var conservativeIdentityKey: String = ""+        public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue+        public var identityURLRuleID: UUID?+        public var identityURLRuleVersion: Int?+        public var identityNameTitleRuleID: UUID?+        public var identityNameTitleRuleVersion: Int?+        public var urlWorkIdentity: String?+        public var urlWorkRuleID: UUID?+        public var urlWorkRuleVersion: Int?+        public var chapterSequence: String?+        public var chapterSequenceRuleID: UUID?+        public var chapterSequenceRuleVersion: Int?+        public var chapterTitle: String?+        public var chapterTitleProvenanceRaw: String = FieldProvenanceKind.none.rawValue+        public var chapterPatternID: UUID?+        public var chapterPatternVersion: Int?+        public var note: String = ""+        public var ratingRaw: String?+        public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)+        public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var work: Work?+        public var workAssignmentProvenanceRaw: String = FieldProvenanceKind.none.rawValue+        public var workPatternID: UUID?+        public var workPatternVersion: Int?+        public var workURLRuleID: UUID?+        public var workURLRuleVersion: Int?+        public var workURLAssignmentKindRaw: String?+        public var intentionallyUnattached: Bool = false++        public init() {}+    }++    @Model+    public final class Work {+        public var id: UUID = UUID()+        public var displayTitle: String = ""+        public var lastParsedTitle: String?+        public var siteHostname: String = ""+        public var site: Site?+        public var urlIdentity: String?+        public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue+        public var urlIdentityRuleID: UUID?+        public var urlIdentityRuleVersion: Int?+        public var workURLString: String?+        public var genericNotes: String = ""+        public var typeRaw: String = WorkType.other.rawValue+        public var workTypeID: UUID?+        public var genreTags: [String] = []+        public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        @Relationship(deleteRule: .nullify, inverse: \Entry.work)+        public var entries: [Entry]?++        public init() {}     } -    public static var stages: [MigrationStage] {-        [.lightweight(fromVersion: AsterismSchemaV5.self, toVersion: AsterismSchemaV6.self)]+    @Model+    public final class Site {+        public var hostname: String = ""+        public var displayName: String = ""+        public var modeRaw: String = SiteMode.untaught.rawValue+        @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)+        public var patterns: [TitlePattern]?+        @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)+        public var urlRules: [URLRulePattern]?+        /// Inverse of `Entry.site`, present only because CloudKit requires every+        /// relationship to have one. Internal for the same reason the live class+        /// keeps it internal (Q17): traversing it faults every Entry for a+        /// hostname.+        @Relationship(deleteRule: .nullify, inverse: \Entry.site)+        var entries: [Entry]?+        /// Inverse of `Work.site`. Same reasoning as `entries`.+        @Relationship(deleteRule: .nullify, inverse: \Work.site)+        var works: [Work]?+        public var urlIdentityRule: URLIdentityRule?+        public var junkSuffixRule: JunkSuffixRule?++        public init() {}+    }++    @Model+    public final class TitlePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isActive: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var formRaw: String = PatternForm.segment.rawValue+        public var segmentWorkAnchor: SegmentRangeSpec?+        public var segmentIgnoredAnchors: [SegmentPositionSpec]?+        public var phrasePrefix: String?+        public var phraseSeparator: String?+        public var phraseSuffix: String?+        public var fieldOrderRaw: String?+        public var trimPrefix: String?+        public var trimSuffix: String?+        public var chapterless: Bool = false+        public var site: Site?++        public init() {}+    }++    @Model+    public final class URLRulePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isCurrent: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var originRaw: String = URLRuleOrigin.readerTaught.rawValue+        public var definitionData: Data = Data()+        public var site: Site?++        public init() {}+    }++    @Model+    public final class WorkTypeEntity {+        public var id: UUID = UUID()+        public var name: String = ""+        public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var stateRaw: String = WorkTypeState.active.rawValue+        public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var canonicalID: UUID?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}     } }
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift Added +51 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swiftnew file mode 100644index 0000000..1a1c4a1--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV7.swift@@ -0,0 +1,51 @@+import Foundation+import SwiftData++/// The runtime schema. Its body is `Models.swift`, which opens+/// `extension AsterismSchemaV7`.+///+/// V7 is V6 plus what `character-extraction` needs: the `Character` table+/// (Decision 1 — reader-authored data), the `CharacterSuppression` table+/// (Q60/Q72 — a system record, never torn), and the two derived coverage+/// fingerprints `Entry.characterExtractionFingerprint` and+/// `Work.genericNotesExtractionFingerprint` (Q59).+///+/// Every addition is CloudKit-legal by construction: every property is defaulted+/// or optional, nothing is unique, and both new relationships (`Character.work`,+/// `CharacterSuppression.work`) are `.nullify` with an inverse on `Work`+/// (Q58).+public enum AsterismSchemaV7: VersionedSchema {+    public static let versionIdentifier = Schema.Version(7, 0, 0)++    public static var models: [any PersistentModel.Type] {+        [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,+         WorkTypeEntity.self, Character.self, CharacterSuppression.self]+    }+}++/// The migration plan: `[V5, V6, V7]`, two lightweight stages.+///+/// **The V5 stage stays** (Q80). Retiring it would carry+/// `retire-migration-chain` Decision 6's population precondition — every device+/// verified past the migration — for no gain here, and dropping it would make+/// every V5-seeded fixture unopenable, because declaring a plan makes a store+/// older than the plan's oldest schema fail closed rather than convert+/// implicitly (`docs/agent-notes/schema-migration.md`).+///+/// Both stages are `.lightweight`: V6 → V7 adds two tables and two columns and+/// changes nothing that exists, so `ModelContainer.init` runs the whole+/// conversion and no data pass accompanies it. Neither is `.custom`: a custom+/// stage never fires between structurally identical models, and it would also+/// run inside the share extension, which must never migrate.+public enum AsterismV7MigrationPlan: SchemaMigrationPlan {+    public static var schemas: [any VersionedSchema.Type] {+        [AsterismSchemaV5.self, AsterismSchemaV6.self, AsterismSchemaV7.self]+    }++    public static var stages: [MigrationStage] {+        [+            .lightweight(fromVersion: AsterismSchemaV5.self, toVersion: AsterismSchemaV6.self),+            .lightweight(fromVersion: AsterismSchemaV6.self, toVersion: AsterismSchemaV7.self),+        ]+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift Modified +27 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swiftindex 5a5422a..b8d62bb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift@@ -61,12 +61,22 @@ enum BackupGroupProjection {         let types: WorkTypeDirectory         let entries: [EntryGroup]         let works: [WorkGroup]+        /// Every character in the store, as logical records — enumerated whole+        /// rather than works→children, so a sync orphan exports with a nil work+        /// reference instead of silently vanishing from the backup (Q78).+        ///+        /// In UUID order, and **read by the 6/7 payload**: 4/4 and 5/6 have+        /// nowhere to write characters but the torn-group refusal applies to+        /// every format, so the grouping happens once here and the one format+        /// that can carry them takes it from here rather than repeating it.+        let characters: [CharacterGroup]     }      /// - Throws: `BackupV4ExportError.tornGroups` when the store holds a torn     ///   group — the biconditional of Req 8.1, since nothing else here refuses.     static func project(-        entries: [Entry], works: [Work], types: WorkTypeDirectory+        entries: [Entry], works: [Work], characters: [CharacterRecord] = [],+        types: WorkTypeDirectory     ) throws -> Projection {         // The Definitions' assignment normalisation (Q106): an Entry group whose         // rows point at two members of one Work set is *not* torn, and the one@@ -78,13 +88,20 @@ enum BackupGroupProjection {         let entryGroups = LibraryRepository.entryGroups(             entries, canonicalWorkIDs: canonicalWorkIDs)         let workGroups = LibraryRepository.workGroups(works, types: types)+        let characterGroups = LibraryRepository.characterGroups(characters)          let tornEntries = entryGroups.values.filter(\.isTorn)         let tornWorks = workGroups.values.filter(\.isTorn)-        guard tornEntries.isEmpty, tornWorks.isEmpty else {+        // Req 6.5: a torn character group is the same thing an archive cannot+        // hold — one record with two authored values — so it refuses the export+        // exactly as a torn Entry or Work does. Without this arm the refusal has+        // no site at all and a torn character would export one variant silently.+        let tornCharacters = characterGroups.values.filter(\.isTorn)+        guard tornEntries.isEmpty, tornWorks.isEmpty, tornCharacters.isEmpty else {             throw BackupV4ExportError.tornGroups(                 tornGroupsPayload(                     tornEntries: tornEntries, tornWorks: tornWorks,+                    tornCharacters: tornCharacters,                     entries: entries, workSets: workSets))         } @@ -92,7 +109,8 @@ enum BackupGroupProjection {             canonicalWorkIDs: canonicalWorkIDs,             types: types,             entries: entryGroups.values.sorted { $0.id.uuidString < $1.id.uuidString },-            works: workGroups.values.sorted { $0.id.uuidString < $1.id.uuidString })+            works: workGroups.values.sorted { $0.id.uuidString < $1.id.uuidString },+            characters: characterGroups.values.sorted { $0.id.uuidString < $1.id.uuidString })     }      /// Req 8.4's second arm. The blocking Work set is named only when every torn@@ -102,11 +120,15 @@ enum BackupGroupProjection {     private static func tornGroupsPayload(         tornEntries: [EntryGroup],         tornWorks: [WorkGroup],+        tornCharacters: [CharacterGroup],         entries: [Entry],         workSets: [WorkDuplicateSet]     ) -> TornGroupsPayload {-        let count = tornEntries.count + tornWorks.count-        guard tornWorks.isEmpty else {+        let count = tornEntries.count + tornWorks.count + tornCharacters.count+        // A torn character waits behind nothing — its set has one member and no+        // assignment (Q76) — so naming a blocking Work set would send the reader+        // to a decision that unblocks only part of the refusal.+        guard tornWorks.isEmpty, tornCharacters.isEmpty else {             return TornGroupsPayload(count: count, blockingWorkSet: nil)         }         let entrySets = DuplicateScan.entrySets(of: entries, workSets: workSets)
Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift Added +149 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swiftnew file mode 100644index 0000000..c374317--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift@@ -0,0 +1,149 @@+import Foundation+import SwiftData++// The character half of a 6/7 import (Req 6.1).+//+// **Additive, UUID-keyed, and never a deletion** — the upsert's posture, applied+// to the three arrays this generation adds:+//+// * a character matches by application UUID and is value-guarded by+//   `modifiedAt`, so an older archive cannot regress a newer edit, and a *torn*+//   group is skipped whole exactly as a torn Work or Entry is (applying an+//   archive over it would overwrite a variant the reader owes a decision on);+// * a suppression matches by row UUID and is value-guarded by `actionAt`, which+//   is the same comparable Q82's read-through uses — so an archived suppression+//   can never undo a newer clear (Req 6.6);+// * a coverage pair carries no timestamp and needs none: it is imported exactly+//   where its fingerprint still describes the source's current text, and dropped+//   otherwise (Q81).+//+// Every insert is match-guarded and every write is value-guarded, which is where+// idempotence comes from: importing the same archive twice writes nothing the+// second time.++extension LibraryRepository {++    /// Merges an archive's characters, suppressions and coverage into the live+    /// library. Does not save — the caller's chunk save covers it.+    ///+    /// - Parameters:+    ///   - workTargets: the Work row each application UUID's group points at,+    ///     as the Work step already computed it. A character whose work is not+    ///     in the map lands unattached, which is the tolerated in-flight state+    ///     Req 6.7 names rather than a reason to drop the record.+    internal static func mergeImportedCharacters(+        _ payload: BackupV6Payload,+        workTargets: [UUID: Work],+        workRows: [UUID: [Work]],+        entryRows: [UUID: [Entry]],+        context: ModelContext+    ) throws {+        var characterRows = Dictionary(+            grouping: try context.fetch(FetchDescriptor<CharacterRecord>()), by: \.id)+        for record in payload.characters {+            let target = record.workID.flatMap { workTargets[$0] }+            if let rows = characterRows[record.id], !rows.isEmpty {+                guard let group = characterGroup(id: record.id, rows: rows), !group.isTorn,+                    record.modifiedAt >= group.modifiedAt+                else { continue }+                // Every row of the group takes the write, or the archive lands+                // on one row and tears the group it was applying to (Req 2.7's+                // rule, and Q85's).+                for row in group.rows {+                    apply(record, to: row)+                    if let target { row.work = target }+                }+            } else {+                let character = CharacterRecord(+                    id: record.id, name: record.name, nameKey: record.nameKey,+                    aliases: record.aliases, note: record.note, facts: record.facts,+                    timestamp: record.createdAt)+                character.modifiedAt = record.modifiedAt+                context.insert(character)+                character.work = target+                characterRows[record.id] = [character]+            }+        }++        var suppressionRows = Dictionary(+            grouping: try context.fetch(FetchDescriptor<CharacterSuppression>()), by: \.id)+        for record in payload.suppressions {+            let target = record.workID.flatMap { workTargets[$0] }+            if let rows = suppressionRows[record.id], !rows.isEmpty {+                for row in rows where record.actionAt >= row.actionAt {+                    apply(record, to: row)+                    if let target { row.work = target }+                }+            } else {+                let row = CharacterSuppression(+                    id: record.id, kind: record.kind, nameKey: record.nameKey,+                    source: record.source, evidence: record.evidence, status: record.status,+                    actionAt: record.actionAt)+                // The raw columns verbatim, so a value written by a later+                // build's wider set survives the round trip rather than being+                // coerced to this build's default.+                row.kindRaw = record.kindRaw+                row.statusRaw = record.statusRaw+                context.insert(row)+                row.work = target+                suppressionRows[record.id] = [row]+            }+        }++        applyImportedCoverage(+            payload.coverage, workRows: workRows, entryRows: entryRows)+    }++    /// Q81's self-validation, over every row of the addressed group.+    private static func applyImportedCoverage(+        _ records: [BackupV6Coverage],+        workRows: [UUID: [Work]],+        entryRows: [UUID: [Entry]]+    ) {+        for record in records {+            switch record.sourceKind {+            case .genericNotes:+                for row in workRows[record.recordID] ?? []+                where CharacterCoverageFingerprint.of(row.genericNotes) == record.fingerprint {+                    row.genericNotesExtractionFingerprint = record.fingerprint+                }+            case .entry:+                for row in entryRows[record.recordID] ?? []+                where CharacterCoverageFingerprint.of(row.note) == record.fingerprint {+                    row.characterExtractionFingerprint = record.fingerprint+                }+            case nil:+                // A discriminator no build writes. The codec refuses one before+                // the plan exists; a hand-built plan reaching here is ignored+                // rather than guessed at.+                continue+            }+        }+    }++    /// The mutable half of an archived character, shared by the insert and the+    /// update so the two cannot drift apart.+    ///+    /// `nameKey` travels rather than being re-derived: it is retained through+    /// renames (Q19/Q46), and recomputing it from `name` would silently re-key+    /// every character an archive restored.+    internal static func apply(_ record: BackupV6Character, to character: CharacterRecord) {+        character.name = record.name+        character.nameKey = record.nameKey+        character.aliases = record.aliases+        character.note = record.note+        character.facts = record.facts+        character.createdAt = record.createdAt+        character.modifiedAt = record.modifiedAt+    }++    internal static func apply(_ record: BackupV6Suppression, to row: CharacterSuppression) {+        row.kindRaw = record.kindRaw+        row.nameKey = record.nameKey+        row.sourceKindRaw = record.sourceKindRaw+        row.sourceEntryID = record.sourceEntryID+        row.evidence = record.evidence+        row.statusRaw = record.statusRaw+        row.actionAt = record.actionAt+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift Modified +8 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swiftindex 7de5ce7..9ed314d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift@@ -21,13 +21,18 @@ extension LibraryRepository {     /// something changed.     ///     /// - Parameters:+    ///   - workTypes: the archive's type list, and `works` the records citing+    ///     it. Taken as the two arrays rather than a payload because every+    ///     generation that carries a type list carries the *same* two record+    ///     types (Q63) — a per-generation overload would be one body twice.     ///   - exportedAt: the archive's own timestamp, worn by rows minted from a     ///     work's `typeName` — there is no list record to take one from (Q33).     ///   - importedAt: the quantized import-time clock, worn *only* by the     ///     restore of an entry this library already held. That one write is the     ///     reader acting now, and it has to assert over a removal older than it.     internal static func mergeImportedWorkTypes(-        _ payload: BackupV5Payload,+        workTypes: [BackupV5WorkTypeRecord],+        works: [BackupV5Work],         exportedAt: Date,         importedAt: Date,         context: ModelContext,@@ -57,7 +62,7 @@ extension LibraryRepository {             wrote = true         } -        for entry in archivedTypeIdentities(payload.workTypes) {+        for entry in archivedTypeIdentities(workTypes) {             // Req 7.8: a name that is empty or carries control characters never             // becomes a visible type. A `merged` entry is not a visible type —             // it is a redirection — so it is admitted whatever it is called,@@ -114,7 +119,7 @@ extension LibraryRepository {         // Citations arrive pre-validated: `unresolvedTypeCitations` checks the         // name before its per-id dedup so an invalid snapshot cannot shadow a         // valid one.-        for citation in unresolvedTypeCitations(payload.works, in: local) {+        for citation in unresolvedTypeCitations(works, in: local) {             let name = WorkTypeName.trimmed(citation.name)             // Re-asked per citation rather than once: a mint two citations ago is             // what the next same-named one has to alias to, or one archive would
Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift Modified +75 / -19
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex f74e954..a88cb04 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -5,21 +5,24 @@ import OSLog  /// The records an accepted archive carries, and which format wrote them. ///-/// Two cases rather than one up-converted shape, because the two formats mean-/// genuinely different things by an untyped work and the difference is not-/// expressible in one record. A 4/4 archive says "untyped as a pre-feature build-/// can say it", which must not untype a configured or unrecognised assignment-/// (Q35); a 5/6 archive says exactly what it means. Everything *else* the two+/// One case per generation rather than one up-converted shape, because the+/// generations mean genuinely different things by an untyped work and the+/// difference is not expressible in one record. A 4/4 archive says "untyped as a+/// pre-feature build can say it", which must not untype a configured or+/// unrecognised assignment (Q35); a 5/6 archive says exactly what it means, and+/// a 6/7 archive says it while also carrying characters. Everything *else* they /// carry is literally the same record types (Decision 12), which is why the four-/// shared arrays read straight off either case.+/// shared arrays read straight off any case. public enum BackupImportPayload: Sendable, Equatable {     case v4Archive(BackupV4Payload)     case v5Archive(BackupV5Payload)+    case v6Archive(BackupV6Payload)      public var entries: [BackupV4Entry] {         switch self {         case .v4Archive(let payload): payload.entries         case .v5Archive(let payload): payload.entries+        case .v6Archive(let payload): payload.entries         }     } @@ -27,6 +30,7 @@ public enum BackupImportPayload: Sendable, Equatable {         switch self {         case .v4Archive(let payload): payload.sites         case .v5Archive(let payload): payload.sites+        case .v6Archive(let payload): payload.sites         }     } @@ -34,6 +38,7 @@ public enum BackupImportPayload: Sendable, Equatable {         switch self {         case .v4Archive(let payload): payload.titlePatterns         case .v5Archive(let payload): payload.titlePatterns+        case .v6Archive(let payload): payload.titlePatterns         }     } @@ -41,16 +46,29 @@ public enum BackupImportPayload: Sendable, Equatable {         switch self {         case .v4Archive(let payload): payload.urlRules         case .v5Archive(let payload): payload.urlRules+        case .v6Archive(let payload): payload.urlRules         }     }      /// How many Work records the archive holds. A count rather than the records-    /// themselves: the two formats' Work records are different types, and every+    /// themselves: the formats' Work records are different types, and every     /// caller outside the commit loop only ever wanted the number.     public var workCount: Int {         switch self {         case .v4Archive(let payload): payload.works.count         case .v5Archive(let payload): payload.works.count+        case .v6Archive(let payload): payload.works.count+        }+    }++    /// The type list and the works that cite it, for the generations that carry+    /// one. 4/4 carries neither, and its untyped record means something the+    /// merge must not act on (Q35), so it answers with nothing.+    internal var workTypeRecords: (types: [BackupV5WorkTypeRecord], works: [BackupV5Work]) {+        switch self {+        case .v4Archive: ([], [])+        case .v5Archive(let payload): (payload.workTypes, payload.works)+        case .v6Archive(let payload): (payload.workTypes, payload.works)         }     } }@@ -59,10 +77,11 @@ public enum BackupImportPayload: Sendable, Equatable { /// process lease. Represents a complete validated prospective graph ready to be /// materialized atomically. ///-/// Two source versions are accepted: native `4/4`, and the `5/6` this feature-/// mints (Req 7.6). The `2/2` and `3/3` import paths were retired once every-/// archive worth importing had been re-exported at 4/4; recovering a pre-M3.5-/// archive now means checking out a build that still carries those codecs.+/// Three source versions are accepted: native `4/4`, `5/6`, and the `6/7` this+/// feature mints (Req 6.1). The `2/2` and `3/3` import paths were retired once+/// every archive worth importing had been re-exported at 4/4; recovering a+/// pre-M3.5 archive now means checking out a build that still carries those+/// codecs. public struct BackupImportPlan: Sendable, Equatable {     public let metadata: BackupImportMetadata     public let payload: BackupImportPayload@@ -159,18 +178,18 @@ public enum BackupImportError: Error, Equatable, Sendable, CustomStringConvertib /// claims the pair. Builds an immutable `BackupImportPlan` outside the repository /// actor and without a process lease. Never mutates the selected file. ///-/// Import supports exact native `4/4` and `5/6`. Mixed pairs and malformed or-/// future headers reject before repository mutation — which is the same door a-/// *pre-feature* build meets `(5, 6)` at, and why a 5/6 archive cannot half-apply-/// on one (Req 7.6).+/// Import supports exact native `4/4`, `5/6` and `6/7`. Mixed pairs and malformed+/// or future headers reject before repository mutation — which is the same door a+/// *pre-feature* build meets `(6, 7)` at, and why a 6/7 archive cannot half-apply+/// on one (`character-extraction` Req 6.1). public enum BackupImporter {     private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImporter")      // MARK: - Plan Dispatch (Req 5.1, 5.2, Decision 2)      /// Builds a validated prospective import plan from raw backup data. Native-    /// `4/4` and `5/6` are the accepted source versions; everything else is-    /// unsupported. Runs entirely outside the repository actor and holds no+    /// `4/4`, `5/6` and `6/7` are the accepted source versions; everything else+    /// is unsupported. Runs entirely outside the repository actor and holds no     /// process lease.     public static func plan(from data: Data) throws -> BackupImportPlan {         logger.debug("Building import plan from \(data.count) bytes")@@ -183,6 +202,8 @@ public enum BackupImporter {             return try planFromV4Archive(data)         case (5, 6):             return try planFromV5Archive(data)+        case (6, 7):+            return try planFromV6Archive(data)         case (2, 2), (3, 3):             // Recognised, and deliberately no longer readable. Say so plainly:             // these archives were restorable until the older import paths were@@ -191,7 +212,7 @@ public enum BackupImporter {                 reason: """                     this backup is in the older \(formatVersion)/\(schemaVersion) \                     format, which this version of Asterism can no longer restore. \-                    Only backups exported by a recent version (4/4 or 5/6) can be \+                    Only backups exported by a recent version (4/4, 5/6 or 6/7) can \                     imported.                     """             )@@ -199,7 +220,7 @@ public enum BackupImporter {             throw BackupImportError.unsupportedFormat(                 reason:                     "format \(formatVersion)/schema \(schemaVersion) is not supported; "-                    + "expected 4/4 or 5/6"+                    + "expected 4/4, 5/6 or 6/7"             )         }     }@@ -248,6 +269,28 @@ public enum BackupImporter {             metadata: metadata, payload: .v5Archive(document.payload), counts: counts)     } +    private static func planFromV6Archive(_ data: Data) throws -> BackupImportPlan {+        logger.debug("Decoding native Backup V6")+        let document: BackupV6Document+        do {+            document = try BackupV6Codec.decode(data)+        } catch {+            throw BackupImportError.decodingFailed(reason: String(describing: error))+        }+        let counts = try validateV6(document.payload)+        let metadata = BackupImportMetadata(+            formatVersion: document.backupFormatVersion,+            schemaVersion: document.databaseSchemaVersion,+            appBuild: document.appBuild,+            exportedAt: document.exportedAt,+            capabilityGate: document.capabilityGate,+            entryCount: document.entryCount,+            workCount: document.workCount+        )+        return BackupImportPlan(+            metadata: metadata, payload: .v6Archive(document.payload), counts: counts)+    }+     /// Materializes and V4-validates a prospective import graph entirely in     /// memory, so a malformed composed tuple fails before any store or readiness     /// marker can be touched.@@ -274,6 +317,19 @@ public enum BackupImporter {         }     } +    /// The same gate over a 6/7 graph. The characters, suppressions and coverage+    /// ride into the in-memory store with the rest, so a payload whose records+    /// contradict the schema fails here rather than at the commit.+    private static func validateV6(_ payload: BackupV6Payload) throws -> LibraryRecordCounts {+        do {+            return try LibraryRepository.validateImportPlanPayloadV6(payload)+        } catch {+            throw BackupImportError.validationFailed(+                reason: "prospective V6 graph failed validation: \(error)"+            )+        }+    }+     // MARK: - Version Detection      /// Reads the format and schema versions from the JSON envelope without
Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift Modified +2 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swiftindex 2dc2288..e1a7f6e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift@@ -1,4 +1,3 @@-import CryptoKit import Foundation  // Extracted from the retired `LegacyBackupV2Codec` when the 2/2 and 3/3 import@@ -22,8 +21,7 @@ internal enum BackupCanonicalJSON {      /// Deterministic bytes: sorted keys so a re-encode reproduces the checksum,     /// unescaped slashes so a URL reads as itself in the file.-    static let outputFormatting: JSONEncoder.OutputFormatting =-        [.sortedKeys, .withoutEscapingSlashes]+    static let outputFormatting: JSONEncoder.OutputFormatting = .canonical      /// The encoder both codecs write with — envelope, payload, and the checksum     /// re-encode alike.@@ -49,9 +47,7 @@ internal enum BackupCanonicalJSON {     }      /// The payload checksum's digest, lowercase hex.-    static func sha256Hex(_ data: Data) -> String {-        SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()-    }+    static func sha256Hex(_ data: Data) -> String { Hexadecimal.sha256(data) }      static func encodeDate(_ date: Date, encoder: Encoder) throws {         var container = encoder.singleValueContainer()
Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift Modified +8 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swiftindex b4fab94..09e8409 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV4Exporter.swift@@ -173,8 +173,15 @@ extension LibraryRepository: BackupV4SnapshotProviding {         // shape the archive cannot hold is a torn group. The projection is what         // replaced `requireUniqueIdentities`, which refused over every repeated         // UUID including the ones that agree.+        // Characters are enumerated whole, never works→children (Q78): a+        // character whose work has not arrived is a tolerated in-flight state+        // (Req 6.7), and a child-of-work walk would drop it out of the backup+        // silently. 4/4 and 5/6 have nowhere to write them, but the torn-group+        // refusal applies to every format — an archive of a torn character is+        // one record with two authored values whichever generation writes it.+        let characters = try context.fetch(FetchDescriptor<CharacterRecord>())         let groups = try BackupGroupProjection.project(-            entries: entries, works: works,+            entries: entries, works: works, characters: characters,             types: workTypeDirectory(context: context))         // Req 4.5, Q14: a URL rule whose stored definition will not decode         // cannot be archived — the record mapper needs a typed value for every
Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift Modified +15 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swiftindex a4eff6a..d0768f5 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV5Exporter.swift@@ -91,6 +91,20 @@ extension LibraryRepository: BackupV5SnapshotProviding {     /// `projectCommonArchiveRecords`, which is what keeps a 5/6 and a 4/4 backup     /// of one library describing one library.     internal static func projectV5Payload(context: ModelContext) throws -> BackupV5Payload {+        try projectV5Payload(from: context).payload+    }++    /// The same, keeping the common projection.+    ///+    /// 6/7 is 5/6 plus three character arrays, and every input it needs — the+    /// character groups, the work groups, the entry groups — is already in+    /// `common`. Re-deriving them (a second whole-table fetch of characters, and+    /// a third of works and entries for the coverage pairs) made one export walk+    /// the library twice and gave the two walks two chances to describe two+    /// moments.+    internal static func projectV5Payload(+        from context: ModelContext+    ) throws -> (payload: BackupV5Payload, common: ArchiveCommonProjection) {         let common: ArchiveCommonProjection         do {             // No type refusal (Req 7.1): a raw value outside the closed set is@@ -135,7 +149,7 @@ extension LibraryRepository: BackupV5SnapshotProviding {         } catch let error as BackupV4ExportError {             throw BackupV5ExportError(error)         }-        return payload+        return (payload, common)     }      /// The 5/6 Work record. Identical to the 4/4 mapper but for the type
Packages/AsterismCore/Sources/AsterismCore/BackupV6Codec.swift Added +257 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV6Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Codec.swiftnew file mode 100644index 0000000..0596045--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Codec.swift@@ -0,0 +1,257 @@+import Foundation++/// The strict 6/7 archive codec: canonical JSON, a SHA-256 checksum over the+/// payload bytes, entry/work counts, root-strict envelope validation, typed+/// nested decode, and a reference validator over the shared record checks plus+/// this generation's own rules.+///+/// Cloned from `BackupV5Codec` rather than grown out of it — a shipped archive+/// format is never redefined in place. What the generations share is the+/// *record-level* checking body (`BackupArchiveReferenceChecks`), the envelope+/// shape (`BackupArchiveShapeValidator`) and the canonical JSON settings; what is+/// restated here is everything that names a version.+///+/// The capability gate is pinned to the literal `"m4"`, for the reason 4/4 and+/// 5/6 pin it: the payload is frozen the moment it ships, and a later+/// `AsterismCapabilities.current` must not change what a 6/7 backup declares.+public enum BackupV6Codec {+    /// Pinned literally. 6/7 ships at the m4 gate; a future gate flip cannot+    /// retroactively change what these files say.+    static let gate = "m4"++    // MARK: - Encode++    public static func encode(+        payload: BackupV6Payload,+        metadata: BackupV6Metadata+    ) throws -> Data {+        let encoder = BackupCanonicalJSON.encoder()++        let payloadData = try encoder.encode(payload)+        let checksum = BackupCanonicalJSON.sha256Hex(payloadData)++        let document = BackupV6Document(+            appBuild: metadata.appBuild,+            exportedAt: metadata.exportedAt,+            capabilityGate: Self.gate,+            entryCount: payload.entries.count,+            workCount: payload.works.count,+            checksum: checksum,+            payload: payload+        )++        return try encoder.encode(document)+    }++    // MARK: - Decode++    /// Decodes and validates a 6/7 document. Validates: envelope format/schema,+    /// capability gate, duplicate keys, strict root shape, entry/work counts,+    /// payload checksum, and all references and tuples.+    ///+    /// The version pair is exact. `(6, 6)` and `(5, 7)` are rejected here, not+    /// only at the importer's dispatch: a mismatched pair is a file this codec+    /// cannot claim to understand whichever door it arrived through.+    public static func decode(_ data: Data) throws -> BackupV6Document {+        do {+            try DuplicateJSONKeyValidator.validate(data)+            try BackupV6ShapeValidator.validate(data)++            let document = try BackupCanonicalJSON.decoder()+                .decode(BackupV6Document.self, from: data)++            guard document.backupFormatVersion == BackupV6Document.formatVersion else {+                throw BackupV6CodecError.invalidFormatVersion(document.backupFormatVersion)+            }+            guard document.databaseSchemaVersion == BackupV6Document.schemaVersion else {+                throw BackupV6CodecError.invalidSchemaVersion(document.databaseSchemaVersion)+            }+            guard document.capabilityGate == Self.gate else {+                throw BackupV6CodecError.unsupportedGate(document.capabilityGate)+            }++            guard document.entryCount == document.payload.entries.count else {+                throw BackupV6CodecError.countMismatch(+                    field: "entryCount",+                    expected: document.entryCount,+                    actual: document.payload.entries.count+                )+            }+            guard document.workCount == document.payload.works.count else {+                throw BackupV6CodecError.countMismatch(+                    field: "workCount",+                    expected: document.workCount,+                    actual: document.payload.works.count+                )+            }++            // Verify checksum: re-encode payload with the same settings.+            let payloadData = try BackupCanonicalJSON.encoder().encode(document.payload)+            let computedChecksum = BackupCanonicalJSON.sha256Hex(payloadData)+            guard document.checksum == computedChecksum else {+                throw BackupV6CodecError.checksumMismatch(+                    expected: document.checksum,+                    actual: computedChecksum+                )+            }++            try BackupV6ReferenceValidator.validate(payload: document.payload)++            return document+        } catch let error as BackupV6CodecError { throw error }+        catch let error as BackupCodecError { throw error }+        catch {+            throw BackupV6CodecError.decodingFailed(reason: String(describing: error))+        }+    }+}++// MARK: - V6 Codec Error++public enum BackupV6CodecError: Error, Equatable, Sendable, CustomStringConvertible {+    case encodingFailed(reason: String)+    case decodingFailed(reason: String)+    case invalidFormatVersion(Int)+    case invalidSchemaVersion(Int)+    case unsupportedGate(String)+    case countMismatch(field: String, expected: Int, actual: Int)+    case checksumMismatch(expected: String, actual: String)+    case unresolvedReference(type: String, id: String, reference: String)+    case invalidStateTuple(type: String, id: String, reason: String)++    /// The shared record-level checks' finding, named as a 6/7 refusal.+    internal init(_ issue: BackupArchiveReferenceIssue) {+        switch issue {+        case .unresolvedReference(let type, let id, let reference):+            self = .unresolvedReference(type: type, id: id, reference: reference)+        case .invalidStateTuple(let type, let id, let reason):+            self = .invalidStateTuple(type: type, id: id, reason: reason)+        }+    }++    public var description: String {+        switch self {+        case .encodingFailed(let reason): "Backup V6 encoding failed: \(reason)"+        case .decodingFailed(let reason): "Backup V6 decoding failed: \(reason)"+        case .invalidFormatVersion(let v): "Backup V6 unsupported format version: \(v)"+        case .invalidSchemaVersion(let v): "Backup V6 unsupported schema version: \(v)"+        case .unsupportedGate(let g): "Backup V6 unsupported capability gate: \(g)"+        case .countMismatch(let field, let expected, let actual):+            "Backup V6 \(field) mismatch: header says \(expected), payload has \(actual)"+        case .checksumMismatch(let expected, let actual):+            "Backup V6 checksum mismatch: expected \(expected), computed \(actual)"+        case .unresolvedReference(let type, let id, let reference):+            "Backup V6 \(type) \(id) has unresolved reference: \(reference)"+        case .invalidStateTuple(let type, let id, let reason):+            "Backup V6 invalid \(type) tuple \(id): \(reason)"+        }+    }+}++// MARK: - V6 Metadata++public struct BackupV6Metadata: Sendable {+    public let appBuild: String+    public let exportedAt: Date++    public init(appBuild: String, exportedAt: Date) {+        self.appBuild = appBuild+        self.exportedAt = exportedAt+    }+}++// MARK: - V6 Shape Validator++/// Root-strict envelope validation, over the shape every generation shares.+internal enum BackupV6ShapeValidator {+    static func validate(_ data: Data) throws {+        try BackupArchiveShapeValidator.validate(data)+    }+}++// MARK: - V6 Reference Validator++/// The shared record checks, 5/6's type-list rules, and the three things only+/// 6/7 carries.+///+/// **What it deliberately does not check.** A fact's `sourceEntryID` and a fact+/// suppression's are exempt (Decision 2): a citation whose entry the reader+/// deleted — or whose entry has not synced — is a tolerated state, not+/// corruption, and refusing here would fail a whole backup over routine+/// curation. A coverage record's `recordID` is exempt too, because an imported+/// pair validates itself against the source's current text (Q81), so a pair+/// naming a record the archive does not carry is inert.+///+/// What it does refuse is a payload contradicting itself: two records for one+/// character, suppression or covered revision, a coverage record whose source+/// kind no build writes, and a character or suppression naming a Work the file+/// does not hold. The work reference is **optional, checked when present** —+/// the `validateEntry` `workID` pattern — so an orphan passes (Q78).+internal enum BackupV6ReferenceValidator {+    static func validate(payload: BackupV6Payload) throws {+        do {+            try BackupArchiveReferenceChecks.validate(+                entries: payload.entries,+                works: payload.works.map(\.referenceRecord),+                sites: payload.sites,+                titlePatterns: payload.titlePatterns,+                urlRules: payload.urlRules,+                formatLabel: "V6")+        } catch let issue as BackupArchiveReferenceIssue {+            throw BackupV6CodecError(issue)+        }++        let typeIDs = Set(payload.workTypes.map(\.id))+        guard typeIDs.count == payload.workTypes.count else {+            throw BackupV6CodecError.invalidStateTuple(+                type: "Payload", id: "V6", reason: "duplicate work type ID")+        }+        for work in payload.works where work.workTypeID != nil && work.legacyType != nil {+            throw BackupV6CodecError.invalidStateTuple(+                type: "Work", id: work.id.uuidString,+                reason: "a work carries a configured type or a legacy value, never both")+        }++        let workIDs = Set(payload.works.map(\.id))++        var characterIDs: Set<UUID> = []+        for character in payload.characters {+            guard characterIDs.insert(character.id).inserted else {+                throw BackupV6CodecError.invalidStateTuple(+                    type: "Payload", id: "V6", reason: "duplicate Character ID")+            }+            if let workID = character.workID, !workIDs.contains(workID) {+                throw BackupV6CodecError.unresolvedReference(+                    type: "Character", id: character.id.uuidString, reference: "Work \(workID)")+            }+        }++        var suppressionIDs: Set<UUID> = []+        for suppression in payload.suppressions {+            guard suppressionIDs.insert(suppression.id).inserted else {+                throw BackupV6CodecError.invalidStateTuple(+                    type: "Payload", id: "V6", reason: "duplicate CharacterSuppression ID")+            }+            if let workID = suppression.workID, !workIDs.contains(workID) {+                throw BackupV6CodecError.unresolvedReference(+                    type: "CharacterSuppression", id: suppression.id.uuidString,+                    reference: "Work \(workID)")+            }+        }++        var coveredRevisions: Set<String> = []+        for record in payload.coverage {+            guard record.sourceKind != nil else {+                throw BackupV6CodecError.invalidStateTuple(+                    type: "Coverage", id: record.recordID.uuidString,+                    reason: "unrecognised source kind '\(record.sourceKindRaw)'")+            }+            let key = "\(record.sourceKindRaw)\u{1F}\(record.recordID.uuidString.lowercased())"+            guard coveredRevisions.insert(key).inserted else {+                throw BackupV6CodecError.invalidStateTuple(+                    type: "Coverage", id: record.recordID.uuidString,+                    reason: "two fingerprints for one source revision")+            }+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV6Exporter.swift Added +279 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV6Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Exporter.swiftnew file mode 100644index 0000000..0146307--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Exporter.swift@@ -0,0 +1,279 @@+import Foundation+import SwiftData++// MARK: - V6 Snapshot Providing++/// Provides one coherent 6/7 payload under a shared lock. Isolated from+/// persistence so export can be unit-tested with injected snapshots.+public protocol BackupV6SnapshotProviding: Sendable {+    func backupV6Snapshot() async throws -> BackupV6Payload+}++// MARK: - V6 Export Errors++/// The 6/7 export's refusals — the same four states 5/6 names, restated under+/// this generation's name so a reader is told which export declined.+///+/// `tornGroups` gains a member it did not have: a **character** group whose rows+/// disagree about something the reader wrote refuses the export exactly as a+/// torn Entry or Work does+/// ([6.5](../../../../specs/character-extraction/requirements.md#6.5)). What+/// does *not* refuse is a fact whose citation dangles — Decision 2 makes that a+/// tolerated state, so+/// [6.2](../../../../specs/character-extraction/requirements.md#6.2) holds.+public enum BackupV6ExportError: Error, Equatable, Sendable, CustomStringConvertible {+    /// The store holds a **torn** identity group: one application UUID over rows+    /// that disagree about something the reader wrote.+    case tornGroups(TornGroupsPayload)+    /// A record holds a stored value the wire format cannot represent.+    case unrepresentableValue(record: String, field: String, value: String)+    /// A record cites a rule no row in the library holds. Transient by nature.+    case referencesStillArriving(detail: String)++    case snapshotFailed(reason: String)+    case encodingFailed(reason: String)+    case stagingFailed(reason: String)++    /// The shared projection's refusal, named as a 6/7 refusal. The projection+    /// is one body for every generation, so its findings arrive spelled 4/4.+    internal init(_ error: BackupV4ExportError) {+        switch error {+        case .tornGroups(let payload): self = .tornGroups(payload)+        case .unrepresentableValue(let record, let field, let value):+            self = .unrepresentableValue(record: record, field: field, value: value)+        case .referencesStillArriving(let detail): self = .referencesStillArriving(detail: detail)+        case .snapshotFailed(let reason): self = .snapshotFailed(reason: reason)+        case .encodingFailed(let reason): self = .encodingFailed(reason: reason)+        case .stagingFailed(let reason): self = .stagingFailed(reason: reason)+        }+    }++    internal init(_ error: BackupV5ExportError) {+        switch error {+        case .tornGroups(let payload): self = .tornGroups(payload)+        case .unrepresentableValue(let record, let field, let value):+            self = .unrepresentableValue(record: record, field: field, value: value)+        case .referencesStillArriving(let detail): self = .referencesStillArriving(detail: detail)+        case .snapshotFailed(let reason): self = .snapshotFailed(reason: reason)+        case .encodingFailed(let reason): self = .encodingFailed(reason: reason)+        case .stagingFailed(let reason): self = .stagingFailed(reason: reason)+        }+    }++    public var description: String {+        switch self {+        case .tornGroups(let payload):+            payload.count == 1+                ? "Backup export refused: 1 record exists in differing copies, and a "+                    + "backup cannot hold both"+                : "Backup export refused: \(payload.count) records exist in differing "+                    + "copies, and a backup cannot hold them all"+        case .unrepresentableValue(let record, let field, let value):+            "Backup export refused: \(record) holds \(field) '\(value)', which this "+                + "backup format cannot represent — it was probably written by a newer "+                + "version of Asterism"+        case .referencesStillArriving(let detail):+            "Backup export refused: records are still arriving from iCloud (\(detail)). "+                + "Try again once syncing has settled"+        case .snapshotFailed(let reason): "Backup V6 snapshot failed: \(reason)"+        case .encodingFailed(let reason): "Backup V6 encoding failed: \(reason)"+        case .stagingFailed(let reason): "Backup V6 staging failed: \(reason)"+        }+    }+}++// MARK: - LibraryRepository V6 Snapshot++extension LibraryRepository: BackupV6SnapshotProviding {+    /// Provides a coherent 6/7 backup payload under a shared lock.+    public func backupV6Snapshot() async throws -> BackupV6Payload {+        let outcome: Result<BackupV6Payload, BackupV6ExportError> =+            try await withLockedBackupContext { context in+                do { return .success(try Self.projectV6Payload(context: context)) }+                catch let error as BackupV6ExportError { return .failure(error) }+                catch let error as BackupV5ExportError { return .failure(BackupV6ExportError(error)) }+                catch let error as BackupV4ExportError { return .failure(BackupV6ExportError(error)) }+            }+        return try outcome.get()+    }++    /// The whole 6/7 snapshot, from a context.+    ///+    /// The six frozen arrays are 5/6's projection verbatim — the same body, so a+    /// 5/6 and a 6/7 backup of one library describe one library — and what is+    /// added here are the three character arrays, all three read from the same+    /// context under the same lock.+    internal static func projectV6Payload(context: ModelContext) throws -> BackupV6Payload {+        let frozen: BackupV5Payload+        let common: ArchiveCommonProjection+        do { (frozen, common) = try projectV5Payload(from: context) }+        catch let error as BackupV5ExportError { throw BackupV6ExportError(error) }+        catch let error as BackupV4ExportError { throw BackupV6ExportError(error) }++        // **One projection pass.** The shared projection already enumerated every+        // character whole (Q78 — never works→children, so a sync orphan exports+        // with a nil work reference instead of vanishing), already refused a torn+        // group, and already sorted by UUID. Re-fetching and re-grouping them+        // here was a second answer to a question with one answer, and the field+        // it duplicated was carried on every 4/4 and 5/6 export for nothing.+        let characters = common.groups.characters.map(mapV6CharacterRecord)++        let suppressions = try context.fetch(FetchDescriptor<CharacterSuppression>())+            .map(mapV6SuppressionRecord)+            .sorted { $0.id.uuidString < $1.id.uuidString }++        return BackupV6Payload(+            entries: frozen.entries,+            works: frozen.works,+            sites: frozen.sites,+            titlePatterns: frozen.titlePatterns,+            urlRules: frozen.urlRules,+            workTypes: frozen.workTypes,+            characters: characters,+            suppressions: suppressions,+            coverage: projectV6Coverage(common.groups))+    }++    /// The group's presented content, plus the immutable evidence its carrier+    /// holds. One record per identity, like every other archive record: rows+    /// sharing a UUID are one character everywhere else in the app.+    private static func mapV6CharacterRecord(_ group: CharacterGroup) -> BackupV6Character {+        let content = group.presentedContent+        return BackupV6Character(+            id: group.id,+            workID: group.carrier.work?.id,+            name: content.name,+            nameKey: group.carrier.nameKey,+            aliases: content.aliases,+            note: content.note,+            facts: content.facts,+            createdAt: group.createdAt,+            modifiedAt: group.modifiedAt)+    }++    /// Suppression rows travel one-for-one, duplicates included (Q82): the store+    /// reads them through by `actionAt` rather than folding them, so folding+    /// here would be a second convergence rule that only archives obey.+    private static func mapV6SuppressionRecord(+        _ row: CharacterSuppression+    ) -> BackupV6Suppression {+        BackupV6Suppression(+            id: row.id, workID: row.work?.id, kindRaw: row.kindRaw, nameKey: row.nameKey,+            sourceKindRaw: row.sourceKindRaw, sourceEntryID: row.sourceEntryID,+            evidence: row.evidence, statusRaw: row.statusRaw, actionAt: row.actionAt)+    }++    /// The covered revisions, keyed by the record whose text they describe.+    ///+    /// Written verbatim, including a fingerprint that no longer matches the+    /// text: the archive is a snapshot of what the store holds, and the import+    /// is where a stale pair is dropped (Q81). Read from the carrier row, which+    /// is the row the group presents.+    ///+    /// Takes the shared projection rather than a context: the work and entry+    /// groups it needs are exactly the ones the export has already built, under+    /// the same assignment normalisation (Q106). Re-deriving them was a third+    /// walk of the two largest tables and a third chance to fold them+    /// differently.+    private static func projectV6Coverage(+        _ groups: BackupGroupProjection.Projection+    ) -> [BackupV6Coverage] {+        var records: [BackupV6Coverage] = []+        for group in groups.works {+            guard let fingerprint = group.carrier.genericNotesExtractionFingerprint else {+                continue+            }+            records.append(.genericNotes(work: group.id, fingerprint: fingerprint))+        }++        for group in groups.entries {+            guard let fingerprint = group.carrier.characterExtractionFingerprint else { continue }+            records.append(.entry(group.id, fingerprint: fingerprint))+        }++        return records.sorted {+            ($0.sourceKindRaw, $0.recordID.uuidString) < ($1.sourceKindRaw, $1.recordID.uuidString)+        }+    }+}++// MARK: - V6 Exporter++/// Orchestrates coherent 6/7 snapshot → validated encoding → staging.+///+/// It decode-validates its own bytes before sharing, so a produced file is+/// always a valid strict 6/7 document — the 4/4 exporter's contract, kept.+public final class BackupV6Exporter: Sendable {+    private let repository: any BackupV6SnapshotProviding+    private let stagingDirectory: URL++    public init(+        repository: any BackupV6SnapshotProviding,+        stagingDirectory: URL+    ) {+        self.repository = repository+        self.stagingDirectory = stagingDirectory+    }++    public func export(metadata: BackupV6Metadata) async throws -> BackupExportResult {+        let payload: BackupV6Payload+        do {+            payload = try await repository.backupV6Snapshot()+        } catch let error as BackupV6ExportError {+            throw error+        } catch let error as BackupV5ExportError {+            throw BackupV6ExportError(error)+        } catch let error as BackupV4ExportError {+            throw BackupV6ExportError(error)+        } catch {+            throw BackupV6ExportError.snapshotFailed(reason: String(describing: error))+        }++        let encoded: Data+        do {+            encoded = try BackupV6Codec.encode(payload: payload, metadata: metadata)+        } catch {+            throw BackupV6ExportError.encodingFailed(reason: String(describing: error))+        }++        do {+            let decoded = try BackupV6Codec.decode(encoded)+            guard decoded.payload == payload else {+                throw BackupV6ExportError.encodingFailed(reason: "decode-validation payload mismatch")+            }+        } catch let error as BackupV6ExportError {+            throw error+        } catch {+            throw BackupV6ExportError.encodingFailed(reason: "decode-validation failed: \(error)")+        }++        do {+            try FileManager.default.createDirectory(+                at: stagingDirectory, withIntermediateDirectories: true)+            let fileURL = stagingDirectory.appending(+                path: ExportStaging.backupFilename(+                    version: "v6", exportedAt: metadata.exportedAt))+            do {+                try ExportStaging.write(encoded, to: fileURL)+            } catch {+                throw BackupV6ExportError.stagingFailed(reason: String(describing: error))+            }+            return BackupExportResult(fileURL: fileURL)+        } catch let error as BackupV6ExportError {+            throw error+        } catch {+            throw BackupV6ExportError.stagingFailed(+                reason: "preparing staging directory failed: \(error)")+        }+    }++    public func cleanup(_ result: BackupExportResult) {+        try? FileManager.default.removeItem(at: result.fileURL)+    }++    /// Removes abandoned backup files older than 24 hours from the staging area,+    /// which still covers the 4/4 and 5/6 files a previous build staged.+    public func scavengeStaleFiles() {+        ExportStaging.scavengeBackups(in: stagingDirectory)+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupV6Types.swift Added +263 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV6Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Types.swiftnew file mode 100644index 0000000..eac06fb--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV6Types.swift@@ -0,0 +1,263 @@+import Foundation++// MARK: - Backup V6 Document++/// The 6/7 backup envelope: format version 6 over schema version 7+/// (`character-extraction` Req 6.1, Q63).+///+/// A new document type rather than an edit to the 5/6 one, for the reason 5/6+/// was added beside 4/4: a shipped archive format is never redefined in place.+/// Pre-feature builds meet the `(6, 7)` pair in their importer's default arm and+/// refuse it before touching the library — an accepted consequence, stated in+/// [6.1](../../../../specs/character-extraction/requirements.md#6.1).+///+/// The envelope keys are 4/4's, unchanged. Everything this generation adds is+/// inside `payload`.+public struct BackupV6Document: Codable, Equatable, Sendable {+    public static let formatVersion = 6+    public static let schemaVersion = 7++    public let backupFormatVersion: Int+    public let databaseSchemaVersion: Int+    public let appBuild: String+    public let exportedAt: Date+    public let capabilityGate: String+    public let entryCount: Int+    public let workCount: Int+    public let checksum: String+    public let payload: BackupV6Payload++    public init(+        appBuild: String,+        exportedAt: Date,+        capabilityGate: String,+        entryCount: Int,+        workCount: Int,+        checksum: String,+        payload: BackupV6Payload+    ) {+        backupFormatVersion = Self.formatVersion+        databaseSchemaVersion = Self.schemaVersion+        self.appBuild = appBuild+        self.exportedAt = exportedAt+        self.capabilityGate = capabilityGate+        self.entryCount = entryCount+        self.workCount = workCount+        self.checksum = checksum+        self.payload = payload+    }+}++// MARK: - V6 Payload++/// 5/6's six arrays plus the three this generation adds.+///+/// **Nothing is re-frozen** (Q63). The Entry, Work, Site, TitlePattern, URLRule+/// and work-type records are the 5/6 records *themselves*: schema V7 adds two+/// derived coverage columns and two new entities, and a derived column is not a+/// reason to restate six wire shapes and give them somewhere to drift apart.+/// The coverage array rides beside the frozen records instead, keyed by the+/// UUID of the Entry or Work whose text it describes.+public struct BackupV6Payload: Codable, Equatable, Sendable {+    public let entries: [BackupV4Entry]+    public let works: [BackupV5Work]+    public let sites: [BackupV4Site]+    public let titlePatterns: [BackupV4TitlePattern]+    public let urlRules: [BackupV4URLRule]+    public let workTypes: [BackupV5WorkTypeRecord]+    /// **Every** character in the library, enumerated whole rather than+    /// works→children (Q78): a character that synced ahead of its work exports+    /// with a nil work reference instead of vanishing from the backup.+    public let characters: [BackupV6Character]+    /// Every suppression row, likewise — including rows sync duplicated, which+    /// the store reads through rather than folding (Q82).+    public let suppressions: [BackupV6Suppression]+    /// Which source revisions an extraction pass has covered.+    public let coverage: [BackupV6Coverage]++    public init(+        entries: [BackupV4Entry],+        works: [BackupV5Work],+        sites: [BackupV4Site],+        titlePatterns: [BackupV4TitlePattern],+        urlRules: [BackupV4URLRule],+        workTypes: [BackupV5WorkTypeRecord],+        characters: [BackupV6Character],+        suppressions: [BackupV6Suppression],+        coverage: [BackupV6Coverage]+    ) {+        self.entries = entries+        self.works = works+        self.sites = sites+        self.titlePatterns = titlePatterns+        self.urlRules = urlRules+        self.workTypes = workTypes+        self.characters = characters+        self.suppressions = suppressions+        self.coverage = coverage+    }++    /// The same six arrays a 5/6 archive of this library would hold. What the+    /// import path reads when it is doing the work both generations share.+    public var frozenRecords: BackupV5Payload {+        BackupV5Payload(+            entries: entries, works: works, sites: sites, titlePatterns: titlePatterns,+            urlRules: urlRules, workTypes: workTypes)+    }+}++// MARK: - V6 Records++/// One character, as the archive holds it (Req 6.1).+///+/// `facts` carries `CharacterFact` itself rather than a wire clone of it, the+/// way the 4/4 records carry `TitleProvenance` and `WorkURLIdentityState`: the+/// fact *is* a value type with a stable Codable shape, and a second spelling of+/// it would be two definitions of one thing with no way to notice them drifting.+///+/// `workID` is optional and the validator checks it only when present — a+/// character whose work has not arrived is a tolerated in-flight state+/// ([6.7](../../../../specs/character-extraction/requirements.md#6.7), Q78), a+/// reference to a work the archive does not carry is a file contradicting+/// itself.+public struct BackupV6Character: Codable, Equatable, Sendable {+    public let id: UUID+    public let workID: UUID?+    public let name: String+    /// The retained key, minted at accept or creation and never re-derived from+    /// a rename (Q19/Q46) — so it travels rather than being recomputed on the+    /// way in.+    public let nameKey: String+    public let aliases: [String]+    public let note: String+    public let facts: [CharacterFact]+    public let createdAt: Date+    public let modifiedAt: Date++    public init(+        id: UUID,+        workID: UUID?,+        name: String,+        nameKey: String,+        aliases: [String],+        note: String,+        facts: [CharacterFact],+        createdAt: Date,+        modifiedAt: Date+    ) {+        self.id = id+        self.workID = workID+        self.name = name+        self.nameKey = nameKey+        self.aliases = aliases+        self.note = note+        self.facts = facts+        self.createdAt = createdAt+        self.modifiedAt = modifiedAt+    }+}++/// One suppression row (Req 2.4's durability clause).+///+/// The enum columns travel **raw**, for the reason `BackupV5WorkTypeRecord`'s+/// `stateRaw` does: an archive written by a later build's wider set decodes here+/// rather than refusing, and the store's own `?? .candidate` coercion answers+/// for a value this build cannot name.+public struct BackupV6Suppression: Codable, Equatable, Sendable {+    public let id: UUID+    public let workID: UUID?+    public let kindRaw: String+    public let nameKey: String+    /// Present on fact rows only. Explicit rather than inferred from a nil+    /// `sourceEntryID`, so a malformed row is distinguishable from a+    /// generic-notes citation (Q72).+    public let sourceKindRaw: String?+    public let sourceEntryID: UUID?+    public let evidence: String?+    public let statusRaw: String+    /// When the reader acted — the comparable the import value-guards with, and+    /// the one Q82's convergence reads.+    public let actionAt: Date++    public init(+        id: UUID,+        workID: UUID?,+        kindRaw: String,+        nameKey: String,+        sourceKindRaw: String?,+        sourceEntryID: UUID?,+        evidence: String?,+        statusRaw: String,+        actionAt: Date+    ) {+        self.id = id+        self.workID = workID+        self.kindRaw = kindRaw+        self.nameKey = nameKey+        self.sourceKindRaw = sourceKindRaw+        self.sourceEntryID = sourceEntryID+        self.evidence = evidence+        self.statusRaw = statusRaw+        self.actionAt = actionAt+    }++    public var kind: CharacterSuppressionKind {+        CharacterSuppressionKind(rawValue: kindRaw) ?? .candidate+    }++    public var status: CharacterSuppressionStatus {+        CharacterSuppressionStatus(rawValue: statusRaw) ?? .active+    }++    public var source: SourceRef? {+        SourceRef(kindRaw: sourceKindRaw, entryID: sourceEntryID)+    }+}++/// One covered source revision: the fingerprint of the text a pass processed,+/// beside the UUID of the record holding it (Q63).+///+/// **Self-validating on the way in** (Q81). Coverage carries no timestamp to+/// value-guard with and needs none: a pair is imported exactly where the+/// archived fingerprint still describes the source's current text, and dropped+/// otherwise. That is also why the wire validator does not check `recordID`+/// against the archive's records — a pair naming a record the archive does not+/// carry is inert rather than corrupt.+public struct BackupV6Coverage: Codable, Equatable, Sendable {+    /// `"entry"` or `"genericNotes"` — the discriminator `SourceRef` writes.+    public let sourceKindRaw: String+    /// The Entry's UUID for an entry revision; the Work's for a generic-notes+    /// one.+    public let recordID: UUID+    /// SHA-256 of the covered text — the `VariantID` recipe (Q30).+    public let fingerprint: String++    public init(sourceKindRaw: String, recordID: UUID, fingerprint: String) {+        self.sourceKindRaw = sourceKindRaw+        self.recordID = recordID+        self.fingerprint = fingerprint+    }++    public static func entry(_ id: UUID, fingerprint: String) -> BackupV6Coverage {+        BackupV6Coverage(+            sourceKindRaw: SourceRef.entry(id).kindRaw, recordID: id, fingerprint: fingerprint)+    }++    public static func genericNotes(work id: UUID, fingerprint: String) -> BackupV6Coverage {+        BackupV6Coverage(+            sourceKindRaw: SourceRef.genericNotes.kindRaw, recordID: id, fingerprint: fingerprint)+    }++    /// The kind, or nil for a discriminator no build writes.+    public var sourceKind: CharacterCoverageSourceKind? {+        CharacterCoverageSourceKind(rawValue: sourceKindRaw)+    }+}++/// Which of the two coverage columns a record names. Spelled as its own enum+/// rather than reusing `SourceRef`, because `SourceRef.genericNotes` carries no+/// identifier and the coverage record needs one for both shapes.+public enum CharacterCoverageSourceKind: String, Sendable, Equatable, CaseIterable {+    case entry+    case genericNotes+}
Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift Added +40 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift b/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swiftnew file mode 100644index 0000000..2a679f9--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift@@ -0,0 +1,40 @@+import CryptoKit+import Foundation++// The two recipes every device-independent identifier in this package is built+// from, each spelled **once**.+//+// Both are load-bearing in the same way: a fingerprint, a variant id, an archive+// checksum and a fact blob are all compared across devices, so two spellings of+// either recipe is two chances for one of them to drift and for two devices to+// disagree about whether they hold the same thing.++/// Lowercase hex, and the SHA-256-to-hex digest built on it.+///+/// Four surfaces spelled this by hand: the coverage fingerprint (Q30), the+/// archive payload checksum, `VariantID`, and the fact-blob order component.+internal enum Hexadecimal {++    /// Lowercase, two characters per byte, no separators.+    static func encode(_ bytes: some Sequence<UInt8>) -> String {+        bytes.map { String(format: "%02x", $0) }.joined()+    }++    static func sha256(_ data: Data) -> String { encode(SHA256.hash(data: data)) }++    static func sha256(_ text: String) -> String { sha256(Data(text.utf8)) }+}++extension JSONEncoder.OutputFormatting {++    /// The canonical encoding: sorted keys so a re-encode reproduces the bytes,+    /// unescaped slashes so a URL reads as itself in the output.+    ///+    /// Used by the archive codecs (where the checksum is taken over the encoded+    /// payload and re-taken at decode time), by `GroupOrdering.canonicalDefinition`+    /// and by `CharacterFactCodec` (where a differing byte layout is a **false+    /// tear**, Q75). One constant, because a divergence between any two of them+    /// is invisible until a record splits or a file fails validation.+    internal static let canonical: JSONEncoder.OutputFormatting =+        [.sortedKeys, .withoutEscapingSlashes]+}
Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift Added +244 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swiftnew file mode 100644index 0000000..e952464--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift@@ -0,0 +1,244 @@+import Foundation++// The values the extraction pipeline and the store exchange.+//+// They live in AsterismCore rather than AsterismIntelligence because the+// repository vends and consumes them: the coordinator is the only thing that+// talks to the model, and it may not be the only thing that can describe a+// source, a match target or a decision.++// MARK: - The candidate read (Q78)++/// One source of one work, as the sweep's filter sees it.+public struct CharacterExtractionSource: Sendable, Equatable {+    public let ref: SourceRef+    /// The source's current text. The model request carries this and the work's+    /// display title, and nothing else (Req 1.4, Q42).+    public let text: String+    /// The fingerprint of `text` — the revision's identity (Q30).+    public let fingerprint: String+    /// The fingerprint a pass last covered, or nil where none has.+    public let coveredFingerprint: String?++    public init(ref: SourceRef, text: String, fingerprint: String, coveredFingerprint: String?) {+        self.ref = ref+        self.text = text+        self.fingerprint = fingerprint+        self.coveredFingerprint = coveredFingerprint+    }++    /// Coverage is per revision: editing the note *is* the invalidation.+    public var isCovered: Bool { coveredFingerprint == fingerprint }+}++/// An existing character, as decision-time matching sees it (Req 2.3, Q67).+public struct CharacterMatchTarget: Sendable, Equatable {+    public let id: UUID+    /// The key derived from the character's **current** name — the first tier.+    public let currentNameKey: String+    /// The retained key, minted at accept or creation and never re-derived — the+    /// second tier (Q19/Q46).+    public let retainedKey: String+    /// The alias keys, normalised — the third tier (Q56/Q67).+    public let aliasKeys: [String]+    /// Whether the character's identity group is torn. Acceptance onto a torn+    /// character is refused (Req 2.8, Q48).+    public let isTorn: Bool++    public init(+        id: UUID, currentNameKey: String, retainedKey: String, aliasKeys: [String], isTorn: Bool+    ) {+        self.id = id+        self.currentNameKey = currentNameKey+        self.retainedKey = retainedKey+        self.aliasKeys = aliasKeys+        self.isTorn = isTorn+    }+}++/// The suppressions that stand for one work, read through Q82's convergence.+public struct CharacterSuppressionIndex: Sendable, Equatable {+    /// Name keys a skipped candidate suppressed. Blocks **new candidates only**,+    /// never a bundle for an existing character (Q47).+    public let candidateKeys: Set<String>+    /// Fact identity triples an untick or a deletion suppressed.+    public let factIdentities: Set<CharacterFactIdentity>++    public init(candidateKeys: Set<String>, factIdentities: Set<CharacterFactIdentity>) {+        self.candidateKeys = candidateKeys+        self.factIdentities = factIdentities+    }++    public static let empty = CharacterSuppressionIndex(+        candidateKeys: [], factIdentities: [])+}++/// One work as the sweep sees it — **the filter's entire input** (Q78), read in+/// one locked context so no part of it can describe a different moment from+/// another.+public struct CharacterExtractionCandidate: Sendable, Equatable {+    public let workID: UUID+    public let displayTitle: String+    /// The ordering key: the newest of the noted entries'+    /// `max(modifiedAt, lastSharedAt)` and — where the generic notes are+    /// non-empty — the work's own `modifiedAt` (Q71/Q84).+    public let recency: Date+    /// Generic notes first, then the noted entries in capture order.+    public let sources: [CharacterExtractionSource]+    public let characters: [CharacterMatchTarget]+    /// Every accepted fact's identity triple, so no pass re-proposes decided+    /// content (Req 1.7).+    public let acceptedFactIdentities: Set<CharacterFactIdentity>+    public let suppressions: CharacterSuppressionIndex++    public init(+        workID: UUID,+        displayTitle: String,+        recency: Date,+        sources: [CharacterExtractionSource],+        characters: [CharacterMatchTarget],+        acceptedFactIdentities: Set<CharacterFactIdentity>,+        suppressions: CharacterSuppressionIndex+    ) {+        self.workID = workID+        self.displayTitle = displayTitle+        self.recency = recency+        self.sources = sources+        self.characters = characters+        self.acceptedFactIdentities = acceptedFactIdentities+        self.suppressions = suppressions+    }++    public var uncoveredSources: [CharacterExtractionSource] {+        sources.filter { !$0.isCovered }+    }++    /// Req 2.3's tiers over this work's characters.+    public func match(nameKey: String) -> CharacterMatchTarget? {+        CharacterMatching.match(nameKey: nameKey, among: characters)+    }+}++/// Req 2.3's matching tiers — **the one implementation** (Q99's reasoning+/// again: two spellings of a match are two devices routing one proposal onto+/// two characters).+///+/// A deterministic total order with no timestamps in it: current-name key, then+/// retained key, then alias key, lowest character UUID within a tier. Proposed+/// aliases deliberately take no part (Q93).+public enum CharacterMatching {+    public static func match(+        nameKey: String, among characters: [CharacterMatchTarget]+    ) -> CharacterMatchTarget? {+        guard !nameKey.isEmpty else { return nil }+        let tiers: [(CharacterMatchTarget) -> Bool] = [+            { $0.currentNameKey == nameKey },+            { $0.retainedKey == nameKey },+            { $0.aliasKeys.contains(nameKey) },+        ]+        for tier in tiers {+            let matches = characters.filter(tier)+            if let best = matches.min(by: { $0.id.uuidString < $1.id.uuidString }) { return best }+        }+        return nil+    }+}++// MARK: - Decisions (Req 2.2)++/// Accepting or skipping one candidate or bundle.+///+/// Ticking is expressed by what the request carries: `facts` are the ones the+/// row still showed as ticked and `untickedFacts` the rest, so one shape covers+/// accept, skip, and per-fact ticks without three near-identical calls.+public enum CharacterDecisionAction: String, Sendable, Equatable {+    case accept+    case skip+}++/// One review-list decision, committed on its own (Q37 — immediately, and+/// independently of the work page's edit mode).+public struct CharacterDecisionRequest: Sendable, Equatable {+    public var workID: UUID+    public var action: CharacterDecisionAction+    /// The name keys the row **displayed** at decision time: the proposal's own+    /// key plus its unstruck proposed aliases (Q92). A skip suppresses exactly+    /// these; an accept clears exactly these.+    public var displayedKeys: [String]+    /// The character the row was shown against, or nil for a row displayed as a+    /// new candidate. Re-verified at commit (Q66).+    public var displayedTargetID: UUID?+    /// The proposed name, as shown. Mints the new character's name and key when+    /// the row is a candidate.+    public var proposedName: String+    /// The unstruck proposed aliases, installed on acceptance (Q92/Q96).+    public var proposedAliases: [String]+    /// The facts the row displayed, minus the ones the reader unticked — so+    /// **not** "the accepted ones": an accept writes them, a skip suppresses+    /// them, and the field is neutral because both paths read it. Keyed to the+    /// *proposal's* name; the commit re-keys them to the resolved character's+    /// retained key (Q79).+    public var facts: [CharacterFact]+    /// The unticked facts, whose identity triples are suppressed (Req 2.4).+    public var untickedFacts: [CharacterFactIdentity]+    /// The sources this decision completes, with the fingerprints they were+    /// proposed from. Verified against current text (Req 2.7) and written as+    /// coverage in the same save (Q65).+    public var completedSources: [CharacterCompletedSource]++    public init(+        workID: UUID,+        action: CharacterDecisionAction,+        displayedKeys: [String] = [],+        displayedTargetID: UUID? = nil,+        proposedName: String = "",+        proposedAliases: [String] = [],+        facts: [CharacterFact] = [],+        untickedFacts: [CharacterFactIdentity] = [],+        completedSources: [CharacterCompletedSource] = []+    ) {+        self.workID = workID+        self.action = action+        self.displayedKeys = displayedKeys+        self.displayedTargetID = displayedTargetID+        self.proposedName = proposedName+        self.proposedAliases = proposedAliases+        self.facts = facts+        self.untickedFacts = untickedFacts+        self.completedSources = completedSources+    }+}++/// A source and the revision a decision was derived from.+public struct CharacterCompletedSource: Sendable, Equatable, Hashable {+    public let ref: SourceRef+    public let fingerprint: String++    public init(ref: SourceRef, fingerprint: String) {+        self.ref = ref+        self.fingerprint = fingerprint+    }+}++/// Why a decision wrote nothing.+public enum CharacterDecisionRefusal: Sendable, Equatable {+    /// Req 2.7: a cited revision changed while the proposal was held. The sheet+    /// discloses and refreshes.+    case staleSource(SourceRef)+    /// Q66: the proposal no longer resolves onto the character the reader was+    /// shown — including a row displayed as new that now matches an existing+    /// character. The refreshed list re-presents it correctly.+    case reRouted(to: UUID?)+    /// Req 2.8: the work, or the character the proposal resolves onto, is torn.+    /// Acceptance is refused; skipping and unticking are not.+    case torn(characterID: UUID?)+    /// The work is gone. `reconcile()` drops the held proposals.+    case workGone+}++public enum CharacterDecisionOutcome: Sendable, Equatable {+    /// The character the decision wrote to or created; nil for a skip, which+    /// writes only system records.+    case committed(characterID: UUID?)+    case refused(CharacterDecisionRefusal)+}
Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift Added +291 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swiftnew file mode 100644index 0000000..9fd34c2--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift@@ -0,0 +1,291 @@+import Foundation++// The value layer under `Character`: what a fact is, what it cites, how a name+// becomes a key, and how a fact list becomes bytes two devices agree on.+//+// All four are pure and live in AsterismCore rather than AsterismIntelligence:+// the store, the archive and the duplicate machinery all need them, and the+// share extension links this package and must never see FoundationModels.++// MARK: - What a fact cites++/// The unit a fact cites and the unit of one model request (requirements'+/// Definitions): one entry's note, or the work's generic notes.+public enum SourceRef: Sendable, Equatable, Hashable, Codable {+    case entry(UUID)+    case genericNotes++    /// The stored discriminator. Explicit on `CharacterSuppression` so a+    /// malformed row is distinguishable from a generic-notes citation (Q72).+    public var kindRaw: String {+        switch self {+        case .entry: "entry"+        case .genericNotes: "genericNotes"+        }+    }++    public var entryID: UUID? {+        switch self {+        case .entry(let id): id+        case .genericNotes: nil+        }+    }++    /// The stored pair read back. Nil for absent or malformed columns: an+    /// `.entry` discriminator with no id, or a discriminator no build writes.+    public init?(kindRaw: String?, entryID: UUID?) {+        switch kindRaw {+        case "entry":+            guard let entryID else { return nil }+            self = .entry(entryID)+        case "genericNotes":+            self = .genericNotes+        default:+            return nil+        }+    }++    /// The canonical token this source contributes to an ordering or a+    /// suppression key. Generic notes sort first, which is the display order+    /// Q88 pins.+    public var orderToken: String {+        switch self {+        case .genericNotes: "0"+        case .entry(let id): "1\u{1F}\(id.uuidString.lowercased())"+        }+    }++    /// Q88's **display** tier — the one spelling, for the work page and the+    /// review sheet alike: generic notes first, then live citations in capture+    /// order, then dangling ones last.+    ///+    /// Deliberately not `orderToken`, which orders entries by UUID. A reader+    /// reads a character's facts as history, and the note's capture order is+    /// what makes them read that way; a dangling citation has no capture order+    /// at all, which is why the tail needs the (quote, statement) tie-break.+    public func displayTier(captureOrder: [UUID: Int]) -> Int {+        switch self {+        case .genericNotes: Int.min+        case .entry(let id): captureOrder[id] ?? Int.max+        }+    }++    // The wire shape is the same two columns the store holds, so an archive+    // record and a stored row describe a citation identically.+    private enum CodingKeys: String, CodingKey { case kind, entryID }++    public init(from decoder: any Decoder) throws {+        let container = try decoder.container(keyedBy: CodingKeys.self)+        let kind = try container.decode(String.self, forKey: .kind)+        let entryID = try container.decodeIfPresent(UUID.self, forKey: .entryID)+        guard let value = SourceRef(kindRaw: kind, entryID: entryID) else {+            throw DecodingError.dataCorruptedError(+                forKey: .kind, in: container,+                debugDescription: "unrecognised source kind \(kind.debugDescription)")+        }+        self = value+    }++    public func encode(to encoder: any Encoder) throws {+        var container = encoder.container(keyedBy: CodingKeys.self)+        try container.encode(kindRaw, forKey: .kind)+        try container.encodeIfPresent(entryID, forKey: .entryID)+    }+}++// MARK: - A fact++/// One discrete statement about a character, citing exactly one source and+/// carrying a verbatim quote from that source's text (requirements'+/// Definitions).+///+/// **Identity is the triple (name key, source, quote)** (Q29). It is not unique+/// within a character — two copies edited apart on two devices share one triple+/// (Q94/Q98) — so a triple suppression covers every copy, and the canonical+/// ordering breaks the tie on `statement` to stay total (Q75).+///+/// `quote` is immutable after acceptance (Q74): editing it would change the+/// identity and reopen dedup. `statement` is the editable text.+public struct CharacterFact: Sendable, Equatable, Hashable, Codable {+    /// The editable text of the fact.+    public var statement: String+    /// The verbatim evidence span, immutable after acceptance (Q74).+    public let quote: String+    /// The key the fact was accepted under — the resolved character's retained+    /// key, not the spelling the model proposed (Q79). This is what makes an+    /// alias spelling of an accepted quote dedup instead of re-proposing.+    public let nameKey: String+    public let source: SourceRef++    public init(statement: String, quote: String, nameKey: String, source: SourceRef) {+        self.statement = statement+        self.quote = quote+        self.nameKey = nameKey+        self.source = source+    }++    /// The suppression and dedup key (Q29). Two facts with one identity are the+    /// same fact however their statements were edited.+    public var identity: CharacterFactIdentity {+        CharacterFactIdentity(nameKey: nameKey, source: source, quote: quote)+    }++    /// The same fact, re-keyed to another character's retained key — what a+    /// combine and a routed acceptance both do (Q79, Decision 4).+    public func rekeyed(to nameKey: String) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    }++    /// The same fact under a different citation — entry duplicate collapse+    /// repointing a citation to the surviving row (Req 3.6).+    public func citing(_ source: SourceRef) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    }++    /// The id a display row takes — **the one spelling**, because the work page+    /// and the review sheet both key reader state (a tick, a strike) on it and+    /// two spellings would key it differently.+    ///+    /// Deliberately not the identity triple: two copies edited apart share one+    /// triple (Q98) and would collide, so the statement is part of the id. The+    /// name key is not — a routed proposal is re-keyed on the way in, and a row+    /// that changed id under the reader would lose its tick.+    public var displayRowID: String {+        [source.orderToken, quote, statement].joined(separator: "\u{1F}")+    }+}++/// The identity triple, as a value that can key a dictionary or a set.+public struct CharacterFactIdentity: Sendable, Equatable, Hashable {+    public let nameKey: String+    public let source: SourceRef+    public let quote: String++    public init(nameKey: String, source: SourceRef, quote: String) {+        self.nameKey = nameKey+        self.source = source+        self.quote = quote+    }+}++// MARK: - Name keys++/// The one normalisation pipeline for character name keys (Q41/Q64).+///+/// Candidate matching, suppression lookup, alias routing and fact keying all ask+/// this type, for the same reason `WorkTypeName` exists: identity that syncs+/// must agree across devices and locales, and any surface asking its own+/// question lets two devices attach the same facts to different characters.+///+/// The recipe is `WorkTypeName.normalize` — trim, compose (NFC), fold+/// case-insensitively with **no locale**, so a device set to Turkish folds `I`+/// the way every other device does — plus one extra step: a single leading+/// English article is stripped (Q64), because "The Crowned One" and "crowned+/// one" were the same character in the prototype corpus.+public enum CharacterNameKey {++    /// The article stripped, after folding. Exactly this one: extending the list+    /// to "a "/"an " is speculation the prototype corpus does not support.+    static let strippedArticle = "the "++    /// **Stripping repeats until the prefix is gone, and that is deliberate.**+    /// Q64 asks for one article; a single pass would not be *idempotent* — "the+    /// the queen" would key to "the queen", which keys again to "queen" — and+    /// idempotence is load-bearing here in a way it is not for work-type names.+    /// A combine stores the source's retained key as a bare alias string (Q91)+    /// and alias matching normalises what it finds, so a key that moves on+    /// re-normalisation would silently stop routing the proposals the combine+    /// exists to redirect. Repeating costs one pathological name ("The The") and+    /// buys the invariant.+    public static func normalize(_ raw: String) -> String {+        var folded = WorkTypeName.normalize(raw)+        while folded.hasPrefix(strippedArticle) {+            folded.removeFirst(strippedArticle.count)+            // Trim again: "The   Crowned One" folds to "the   crowned one", and+            // the key has to be the same as plain "Crowned One"'s.+            folded = folded.trimmingCharacters(in: .whitespacesAndNewlines)+        }+        return folded+    }+}++// MARK: - Canonical encoding++/// `Character.factsData`'s one encoding (Q75).+///+/// Two devices holding the same facts must produce **byte-identical** blobs or+/// the character false-tears, so nothing here may depend on dictionary order,+/// locale, or the order facts arrived in:+///+/// * keys are sorted (`.sortedKeys`),+/// * facts are ordered by (source, quote, statement) — the statement component+///   keeps the order total once edited-apart copies can share a triple (Q98),+/// * no whitespace, no escaping options, no dates.+///+/// A blob that will not decode reads as no facts rather than throwing: this is+/// reader data arriving over CloudKit, and a row that cannot be read must still+/// display its name (Req 6.7).+public enum CharacterFactCodec {++    /// The canonical order (Q75/Q88): generic-notes citations first, then entry+    /// citations by UUID, then quote, then statement.+    public static func canonicalOrder(_ facts: [CharacterFact]) -> [CharacterFact] {+        facts.sorted { lhs, rhs in+            let lhsKey = [lhs.source.orderToken, lhs.quote, lhs.statement, lhs.nameKey]+            let rhsKey = [rhs.source.orderToken, rhs.quote, rhs.statement, rhs.nameKey]+            return lhsKey.lexicographicallyPrecedes(rhsKey)+        }+    }++    public static func encode(_ facts: [CharacterFact]) -> Data? {+        guard !facts.isEmpty else { return nil }+        let encoder = JSONEncoder()+        encoder.outputFormatting = .canonical+        return try? encoder.encode(canonicalOrder(facts))+    }++    public static func decode(_ data: Data?) -> [CharacterFact] {+        guard let data, !data.isEmpty else { return [] }+        guard let facts = try? JSONDecoder().decode([CharacterFact].self, from: data) else {+            return []+        }+        return canonicalOrder(facts)+    }++    /// The bytes a comparison sees. Two rows whose fact lists agree as *sets*+    /// must compare equal however either row's blob was written — including a+    /// row written by a build that ordered them differently — so authored-content+    /// comparison re-encodes rather than comparing the stored bytes.+    public static func canonicalBytes(_ data: Data?) -> Data? {+        encode(decode(data))+    }+}++// MARK: - Suppression enums++/// What a suppression row remembers (Q60): a skipped candidate's name key, or an+/// unticked or deleted fact's identity triple.+public enum CharacterSuppressionKind: String, Sendable, Equatable, CaseIterable, Codable {+    case candidate+    case fact+}++/// Whether the suppression stands. A clear is a *status change*, never a row+/// deletion: deleting the row would let an offline device's copy resurrect it+/// (Q52/Q60).+public enum CharacterSuppressionStatus: String, Sendable, Equatable, CaseIterable, Codable {+    case active+    case cleared+}++// MARK: - Coverage fingerprints++/// The content fingerprint a source revision is covered by (Q30).+///+/// SHA-256 of the source text, hex-encoded — the `VariantID` recipe. Editing a+/// note *is* the invalidation of its old revision's coverage, so the fingerprint+/// is the whole mechanism: nothing needs a timestamp, and an imported coverage+/// pair validates itself by simply matching (Q81).+public enum CharacterCoverageFingerprint {+    public static func of(_ text: String) -> String { Hexadecimal.sha256(text) }+}
Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift Added +315 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swiftnew file mode 100644index 0000000..a91e504--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift@@ -0,0 +1,315 @@+import Foundation+import SwiftData++// Characters in the duplicate/torn machinery (Req 6.4/6.5).+//+// **Character duplicate sets bucket by application UUID and nothing else**+// (Q76). Every other record type also buckets by a *content* relation — an+// Entry's conservative key, a Work's URL identity or parsed title — which is+// what lets two distinct-UUID rows join one set and collapse into each other.+// Req 6.4 forbids exactly that for characters: distinct-UUID duplicates of one+// character are the reader's to combine, edit or delete, never the app's to+// auto-resolve. Dropping the content relation makes a multi-member character set+// unconstructible, so `.merge` is structurally unreachable rather than merely+// unused, and the only character set that exists is a same-UUID split group:+// converged silently where its rows agree, torn to the resolution sheet where+// they do not.++// MARK: - Authored content++/// A character's reader-authored surface: the name, the aliases, the note, and+/// the facts (Decision 1 — all of it is the reader's, and an extraction pass+/// never touches any of it).+///+/// **Never bare** (design §Data model). A character always carries an authored+/// name — a nameless character is not something any path can produce — so every+/// row forms a variant and a two-row group with differing content is always+/// torn. That is a structurally higher tear rate than an Entry's, which is the+/// price Decision 1 records for full editability.+public struct CharacterAuthoredContent: AuthoredContent {+    public var name: String+    public var note: String+    /// Sorted at construction: aliases are a set, and two rows listing the same+    /// aliases in two orders do not disagree.+    public var aliases: [String]+    /// The facts in their canonical encoding (Q75). Stored as the canonical+    /// *bytes* rather than the decoded array so that comparison is one+    /// `Data` compare, and so that a blob a differently ordered writer produced+    /// still compares equal to one this build wrote.+    public var factsData: Data?++    public init(+        name: String = "",+        note: String = "",+        aliases: [String] = [],+        facts: [CharacterFact] = []+    ) {+        self.name = name+        self.note = note+        self.aliases = aliases.sorted()+        factsData = CharacterFactCodec.encode(facts)+    }++    /// The stored form, re-encoded canonically so two rows written by different+    /// builds compare equal.+    public init(name: String, note: String, aliases: [String], factsData: Data?) {+        self.name = name+        self.note = note+        self.aliases = aliases.sorted()+        self.factsData = CharacterFactCodec.canonicalBytes(factsData)+    }++    public static let bare = CharacterAuthoredContent()++    /// Always false — see the type's doc comment. `bare` still exists because+    /// the protocol requires it and `DuplicateMember.authoredContent` falls back+    /// to it; nothing here ever reports itself bare, so that fallback is+    /// unreachable for characters.+    public var isBare: Bool { false }++    public var facts: [CharacterFact] { CharacterFactCodec.decode(factsData) }++    public var orderComponents: [OrderComponent] {+        [+            .string(name),+            .string(note),+            .strings(aliases),+            // Hex rather than raw bytes: `OrderComponent` orders strings, and a+            // fact blob has to contribute a device-independent total order like+            // every other component.+            .absentableString(factsData.map { Hexadecimal.encode($0) }),+        ]+    }+}++public typealias CharacterDuplicateSet = DuplicateSet<CharacterAuthoredContent>++// MARK: - The logical record++/// Every row sharing one character application UUID, as one logical record —+/// the `EntryGroup`/`WorkGroup` shape, with the same rules.+public struct CharacterGroup {+    public let id: UUID+    public let rows: [CharacterRecord]+    public let representative: CharacterRecord+    /// The row holding the content the group presents (Q41).+    public let carrier: CharacterRecord+    public let variants: [AuthoredVariant<CharacterAuthoredContent>]++    public var isSplit: Bool { rows.count > 1 }+    public var isTorn: Bool { variants.count > 1 }++    public var authoredContent: CharacterAuthoredContent? {+        isTorn ? nil : (variants.first?.content ?? .bare)+    }++    public var state: RecordGroupState<CharacterAuthoredContent> {+        if isTorn { return .torn(variants: variants) }+        return isSplit ? .group(rowCount: rows.count) : .single+    }++    public var variantIDs: Set<VariantID> { Set(variants.map(\.id)) }++    public var presentedContent: CharacterAuthoredContent {+        authoredContent ?? variants.first?.content ?? .bare+    }++    public var createdAt: Date { rows.map(\.createdAt).min() ?? .distantPast }+    public var modifiedAt: Date { rows.map(\.modifiedAt).max() ?? .distantPast }+}++extension GroupOrdering {++    public static func authoredContent(of character: CharacterRecord) -> CharacterAuthoredContent {+        CharacterAuthoredContent(+            name: character.name,+            note: character.note,+            aliases: character.aliases,+            factsData: character.factsData)+    }++    /// A character's immutable evidence is which work it belongs to and when it+    /// was created; everything else is authored and comes from the content.+    static func representativeComponents(_ character: CharacterRecord) -> [OrderComponent] {+        [+            .absentableString(character.work?.id.uuidString.lowercased()),+            .string(character.nameKey),+            .date(character.createdAt),+        ]+            + authoredContent(of: character).orderComponents+            + [.date(character.modifiedAt)]+    }++    public static func representativeCharacter(_ rows: [CharacterRecord]) -> CharacterRecord? {+        least(rows, key: representativeComponents)+    }++    public static func sortedCharacterRows(_ rows: [CharacterRecord]) -> [CharacterRecord] {+        stableSorted(rows, key: representativeComponents)+    }++    static func characterRowPrecedes(_ lhs: CharacterRecord, _ rhs: CharacterRecord) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedAscending+    }++    static func characterRowsAreInterchangeable(+        _ lhs: CharacterRecord, _ rhs: CharacterRecord+    ) -> Bool {+        OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))+            == .orderedSame+    }+}++extension LibraryRepository {++    /// The logical record `rows` amount to, or nil where there are none.+    internal static func characterGroup(id: UUID, rows: [CharacterRecord]) -> CharacterGroup? {+        let sorted = GroupOrdering.sortedCharacterRows(rows)+        guard let representative = sorted.first else { return nil }+        let contents = sorted.map(GroupOrdering.authoredContent(of:))+        let variants = GroupOrdering.variants(+            contents: contents, dates: sorted.map(\.createdAt))+        let presented = variants.first?.content+        let carrier = presented.flatMap { content in+            zip(sorted, contents).first { $0.1 == content }?.0+        } ?? representative+        return CharacterGroup(+            id: id, rows: sorted, representative: representative, carrier: carrier,+            variants: variants)+    }++    internal static func characterGroups(_ rows: [CharacterRecord]) -> [UUID: CharacterGroup] {+        var buckets: [UUID: [CharacterRecord]] = [:]+        for row in rows { buckets[row.id, default: []].append(row) }+        return buckets.compactMapValues { rows in characterGroup(id: rows[0].id, rows: rows) }+    }++    internal static func characterRows(+        ids: [UUID], context: ModelContext+    ) throws -> [UUID: [CharacterRecord]] {+        guard !ids.isEmpty else { return [:] }+        return Dictionary(+            grouping: try context.fetch(+                FetchDescriptor<CharacterRecord>(predicate: #Predicate { ids.contains($0.id) })),+            by: \.id)+    }+}++// MARK: - Entry-collapse repointing (Req 3.6)++/// Moving fact citations and suppression source references off a removed Entry+/// row and onto the row that survived it.+///+/// **Every write fans out across the whole character group** (Q85). Rewriting+/// one row of a group changes its authored bytes while its siblings keep the+/// old ones, which makes the group torn — a false tear manufactured by+/// bookkeeping the reader never did. So the rewrite is computed from the group's+/// presented facts and applied to every row in the same transaction.+public enum CharacterCitationRepointing {++    /// Rewrites `characters` and `suppressions` so nothing cites a UUID in+    /// `survivors`' key set any more, and reports how many rows changed.+    ///+    /// `survivors` maps a removed Entry's UUID to the UUID of the row that+    /// survived it. A citation naming no key is left exactly as it is —+    /// including a citation that dangles for another reason, which Decision 2+    /// makes a tolerated state rather than something to repair.+    ///+    /// `timestamp` is the stamp the rewritten rows take, and it **never moves a+    /// row's `modifiedAt` backwards**. The reconciler derives it from the+    /// collapsing Entries rather than a clock (Q56), so it can easily be older+    /// than the character it rewrites — and `CharacterGroup.modifiedAt` is what+    /// `BackupV6Character` carries as its import value guard, so a backwards+    /// stamp would let an older archive overwrite a newer character.+    @discardableResult+    public static func repoint(+        characters: [CharacterRecord],+        suppressions: [CharacterSuppression],+        survivors: [UUID: UUID],+        timestamp: Date+    ) -> Int {+        guard !survivors.isEmpty else { return 0 }+        var changed = 0++        for (_, group) in LibraryRepository.characterGroups(characters) {+            // The group's own facts, read once. A torn group is rewritten from+            // each row's own facts instead: there is no single presented value,+            // and forcing one would silently resolve a tear the reader owes a+            // decision on.+            if group.isTorn {+                for row in group.rows {+                    let rewritten = repointed(row.facts, survivors: survivors)+                    guard let rewritten else { continue }+                    row.facts = rewritten+                    row.modifiedAt = max(row.modifiedAt, timestamp)+                    changed += 1+                }+                continue+            }+            let facts = group.presentedContent.facts+            guard let rewritten = repointed(facts, survivors: survivors) else { continue }+            let bytes = CharacterFactCodec.encode(rewritten)+            for row in group.rows {+                row.factsData = bytes+                row.modifiedAt = max(row.modifiedAt, timestamp)+                changed += 1+            }+        }++        for row in suppressions {+            guard let entryID = row.sourceEntryID, let survivor = survivors[entryID],+                  survivor != entryID+            else { continue }+            row.sourceEntryID = survivor+            changed += 1+        }+        return changed+    }++    /// The same, scoped to the works the collapsing Entries belong to.+    ///+    /// A fact only ever cites a source of its own work, so the works of the rows+    /// a collapse touches are the whole search space — which is what keeps this+    /// off the whole-table read the reconciler's budget could not afford.+    /// Duplicate work rows are enumerated too (a split group's rows each carry+    /// their own `characters` inverse) and deduplicated by object identity.+    @discardableResult+    public static func repoint(+        survivors: [UUID: UUID], in works: [Work], timestamp: Date+    ) -> Int {+        guard !survivors.isEmpty, !works.isEmpty else { return 0 }+        var characters: [CharacterRecord] = []+        var suppressions: [CharacterSuppression] = []+        var seenCharacters: Set<ObjectIdentifier> = []+        var seenSuppressions: Set<ObjectIdentifier> = []+        for work in works {+            for character in work.characterValues+            where seenCharacters.insert(ObjectIdentifier(character)).inserted {+                characters.append(character)+            }+            for suppression in work.characterSuppressionValues+            where seenSuppressions.insert(ObjectIdentifier(suppression)).inserted {+                suppressions.append(suppression)+            }+        }+        return repoint(+            characters: characters, suppressions: suppressions, survivors: survivors,+            timestamp: timestamp)+    }++    /// The rewritten facts, or nil where none of them cited a collapsed row.+    private static func repointed(+        _ facts: [CharacterFact], survivors: [UUID: UUID]+    ) -> [CharacterFact]? {+        var moved = false+        let rewritten = facts.map { fact -> CharacterFact in+            guard case .entry(let id) = fact.source, let survivor = survivors[id],+                  survivor != id+            else { return fact }+            moved = true+            return fact.citing(.entry(survivor))+        }+        return moved ? rewritten : nil+    }+}
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift Modified +85 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 80a17c3..29dbcf7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -221,9 +221,14 @@ enum DuplicateReconciler {         // which is what lets the carrier gate ask whether an entry is *active*         // (Req 8.4) rather than whether it merely exists.         let types = try LibraryRepository.workTypeDirectory(context: context)+        // Character keys are retained like every other type's. Without them a+        // pass would evict the settled state of every character set it just+        // observed, and the next pass would treat each one as a first+        // observation for ever.         ledger.retain(Set(             scan.entrySets.map(\.key) + scan.workSets.map(\.key)-                + scan.titleRuleSets.map(\.key) + scan.urlRuleSets.map(\.key)))+                + scan.titleRuleSets.map(\.key) + scan.urlRuleSets.map(\.key)+                + scan.characterSets.map(\.key)))          result.outcome.formUnion(             try convergeRules(scan, batchSize: batchSize, context: context, saveStrategy: saveStrategy))@@ -241,9 +246,68 @@ enum DuplicateReconciler {         result.deletions += entryPhase.deletions         result.canonicalWorkIDs = entryPhase.canonicalWorkIDs +        result.outcome.formUnion(+            try convergeCharacterGroups(+                scan.characterSets, context: context, saveStrategy: saveStrategy))+         return result     } +    // MARK: - Req 6.4/6.5: character groups++    /// Makes every row of a same-UUID character group hold one authored value,+    /// and reports the divergent ones for the reader (Req 6.4, 6.5).+    ///+    /// It is the rule-convergence shape rather than the Entry/Work shape,+    /// because a character set has exactly one member (Q76): nothing is ever+    /// deleted here, so there is no survivor rule, no settling ledger entry to+    /// wait on, and no deletion plan to hand back. Rows that agree are already+    /// converged and the value guard writes nothing; rows that disagree are+    /// torn, and a tear is the reader's to resolve — silently picking a variant+    /// would be exactly the merge Req 6.4 forbids.+    private static func convergeCharacterGroups(+        _ sets: [CharacterDuplicateSet],+        context: ModelContext,+        saveStrategy: any RepositorySaveStrategy+    ) throws -> DuplicateReconciliationOutcome {+        var outcome = DuplicateReconciliationOutcome()+        let divergent = sets.filter { $0.classification == .divergent }+        for set in divergent { outcome.reviewSetKeys.append(set.key) }++        let resolvable = sets.filter { $0.classification == .silentlyResolvable }+        guard !resolvable.isEmpty else { return outcome }++        let rowsByID = try LibraryRepository.characterRows(+            ids: resolvable.map(\.key).compactMap(\.memberIDs.first), context: context)+        var wrote = false+        for set in resolvable {+            guard let id = set.key.memberIDs.first,+                  let group = LibraryRepository.characterGroup(id: id, rows: rowsByID[id] ?? []),+                  !group.isTorn+            else { continue }+            let content = group.presentedContent+            var setWrote = false+            for row in group.rows {+                if row.name != content.name { row.name = content.name; setWrote = true }+                if row.note != content.note { row.note = content.note; setWrote = true }+                if row.aliases != content.aliases { row.aliases = content.aliases; setWrote = true }+                if row.factsData != content.factsData {+                    row.factsData = content.factsData+                    setWrote = true+                }+            }+            if setWrote {+                outcome.contentWrites += 1+                wrote = true+            }+        }+        // No clock write (Q56): every value above is derived from synced content,+        // so two devices reach the same fixed point. Stamping `modifiedAt` here+        // would make convergence itself an edit and the fixed point unreachable.+        if wrote { try saveStrategy.save(context) }+        return outcome+    }+     // MARK: - Req 6: rule convergence      /// Makes every row of a rule identity group hold one definition, without@@ -856,6 +920,20 @@ enum DuplicateReconciler {                 plan.key.memberIDs, rowsByID: rows.entries, canonicalWorkIDs: canonicalWorkIDs)                 == plan.fingerprint             else { return false }+            // Req 3.6: fact citations and suppression source references follow+            // the surviving row, group-wide so the rewrite cannot false-tear a+            // character (Q85). Before the deletion, while the works are still+            // reachable through the rows about to go.+            //+            // No clock: this is the silent path, and the repointing is derived+            // from synced content like everything else the reconciler writes+            // (Q56). The rows keep the modification stamp the group already had.+            let touched = plan.key.memberIDs.flatMap { rows.entries[$0] ?? [] }+            CharacterCitationRepointing.repoint(+                survivors: Dictionary(+                    uniqueKeysWithValues: plan.loserIDs.map { ($0, plan.survivorID) }),+                in: touched.compactMap(\.work),+                timestamp: touched.map(\.modifiedAt).max() ?? .distantPast)             for id in plan.loserIDs {                 for row in rows.entries[id] ?? [] { context.delete(row) }             }@@ -879,6 +957,12 @@ enum DuplicateReconciler {         case .titleRule, .urlRule:             // Rule groups converge and are never deleted (Decision 4, Q39).             return false+        case .character:+            // Unreachable: a character set has one member and therefore no+            // losers (Q76), so no deletion plan is ever built for one. The arm+            // is explicit rather than folded into the rule arm so that a future+            // change to character bucketing has to be looked at here.+            return false         }     } 
Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift Modified +41 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swiftindex 8616fd9..4edbfa7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift@@ -25,6 +25,10 @@ public enum DuplicateResolutionField: String, Sendable, Equatable, CaseIterable     case workURL     case genreTags     case type+    // Character+    case name+    case aliases+    case facts }  /// One Entry variant as the sheet shows it: every authored field, plus the@@ -90,6 +94,33 @@ public struct WorkVariantChoice: Sendable, Equatable, Identifiable {     } } +/// One character variant as the sheet shows it (Req 6.5, Q87).+///+/// The fact *count* rather than the facts: the sheet is asking which copy of the+/// record to keep, and two copies that differ by one edited statement are told+/// apart by their name, note and count without printing both fact lists into a+/// chooser.+public struct CharacterVariantChoice: Sendable, Equatable, Identifiable {+    public let id: VariantID+    public let name: String+    public let note: String+    public let aliases: [String]+    public let factCount: Int+    public let firstCapturedAt: Date++    public init(+        id: VariantID, name: String, note: String, aliases: [String], factCount: Int,+        firstCapturedAt: Date+    ) {+        self.id = id+        self.name = name+        self.note = note+        self.aliases = aliases+        self.factCount = factCount+        self.firstCapturedAt = firstCapturedAt+    }+}+ /// What the reader is being asked, and the exact basis the confirmation is /// re-verified against (Reqs 4.2, 4.6). ///@@ -109,17 +140,22 @@ public enum DuplicateResolutionContract: Sendable, Equatable {         variants: [WorkVariantChoice],         differingFields: [DuplicateResolutionField],         preselected: VariantID)+    case character(+        setKey: DuplicateSetKey,+        variants: [CharacterVariantChoice],+        differingFields: [DuplicateResolutionField],+        preselected: VariantID)      public var setKey: DuplicateSetKey {         switch self {-        case .entry(let key, _, _, _), .work(let key, _, _, _): key+        case .entry(let key, _, _, _), .work(let key, _, _, _), .character(let key, _, _, _): key         }     }      /// The leading variant (Q42), which the sheet preselects (Req 4.2).     public var preselected: VariantID {         switch self {-        case .entry(_, _, _, let id), .work(_, _, _, let id): id+        case .entry(_, _, _, let id), .work(_, _, _, let id), .character(_, _, _, let id): id         }     } @@ -129,12 +165,14 @@ public enum DuplicateResolutionContract: Sendable, Equatable {         switch self {         case .entry(_, let variants, _, _): variants.map(\.id)         case .work(_, let variants, _, _): variants.map(\.id)+        case .character(_, let variants, _, _): variants.map(\.id)         }     }      public var differingFields: [DuplicateResolutionField] {         switch self {-        case .entry(_, _, let fields, _), .work(_, _, let fields, _): fields+        case .entry(_, _, let fields, _), .work(_, _, let fields, _),+             .character(_, _, let fields, _): fields         }     } 
Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift Modified +78 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swiftindex 05f384f..181e6cc 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift@@ -19,6 +19,10 @@ public enum DuplicateRecordType: String, Sendable, Comparable, CaseIterable {     case work     case titleRule     case urlRule+    /// **Appended last, deliberately.** The case order is the `DuplicateSetKey`+    /// sort order, so inserting a case anywhere else would renumber every+    /// existing key's rank and reorder listings that are pinned by tests.+    case character      public static func < (lhs: Self, rhs: Self) -> Bool {         (Self.allCases.firstIndex(of: lhs) ?? 0) < (Self.allCases.firstIndex(of: rhs) ?? 0)@@ -109,14 +113,37 @@ public struct DuplicateScanResult: Sendable, Equatable {     public let workSets: [WorkDuplicateSet]     public let titleRuleSets: [RuleDuplicateSet]     public let urlRuleSets: [RuleDuplicateSet]+    /// Same-UUID character groups only (Q76). Every set here has exactly one+    /// member, because characters bucket by application UUID and nothing else —+    /// see `CharacterGroups.swift`.+    public let characterSets: [CharacterDuplicateSet]++    /// `characterSets` is defaulted so the callers that build a result from two+    /// record types — the publication tests, and any future partial projection —+    /// do not have to name a type they have no rows for.+    public init(+        entrySets: [EntryDuplicateSet],+        workSets: [WorkDuplicateSet],+        titleRuleSets: [RuleDuplicateSet],+        urlRuleSets: [RuleDuplicateSet],+        characterSets: [CharacterDuplicateSet] = []+    ) {+        self.entrySets = entrySets+        self.workSets = workSets+        self.titleRuleSets = titleRuleSets+        self.urlRuleSets = urlRuleSets+        self.characterSets = characterSets+    }      public var isEmpty: Bool {         entrySets.isEmpty && workSets.isEmpty && titleRuleSets.isEmpty && urlRuleSets.isEmpty+            && characterSets.isEmpty     }      /// Every set, for callers that only need "is there work" or a count.     public var setCount: Int {         entrySets.count + workSets.count + titleRuleSets.count + urlRuleSets.count+            + characterSets.count     }      /// The Definitions' assignment normalisation for this scan (Q38).@@ -181,6 +208,7 @@ public enum DuplicateScan {         var workRows: [WorkRow] = []         var patternRows: [RuleRow] = []         var urlRuleRows: [RuleRow] = []+        var characterRows: [CharacterRecord] = []          if !entryComponents.candidates.isEmpty {             let candidates = entryComponents.candidates@@ -210,6 +238,24 @@ public enum DuplicateScan {                 urlRuleRows.append(RuleRow(id: rule.id, createdAt: rule.createdAt))             }         }+        // Characters keep their model references rather than being copied into a+        // row struct: the set builder needs the group's authored content, and a+        // group here is only ever the rows of one UUID, so nothing is faulted+        // that the group would not have faulted anyway.+        //+        // The same two-walk gate as the other record types (Req 10.2). There is+        // no bucket key — characters relate by application UUID alone (Q76) — so+        // the first walk reads the id column and nothing else, and a library+        // with no split character group never pays for the second.+        let characterCandidates = try candidateComponents(+            FetchDescriptor<CharacterRecord>(), context: context, id: \.id,+            bucketKey: { _ in nil }).candidates+        if !characterCandidates.isEmpty {+            try context.enumerate(FetchDescriptor<CharacterRecord>(), batchSize: batchSize) { row in+                guard characterCandidates.contains(row.id) else { return }+                characterRows.append(row)+            }+        }          // Req 1.5: Work sets first. Entry classification reads their survivors,         // both to normalise assignments and to spot the Req 1.6 blockages.@@ -223,7 +269,8 @@ public enum DuplicateScan {                 divergentWorkSetKeysByMember: divergentWorkSetKeys(workSets)),             workSets: workSets,             titleRuleSets: buildRuleSets(patternRows, type: .titleRule),-            urlRuleSets: buildRuleSets(urlRuleRows, type: .urlRule))+            urlRuleSets: buildRuleSets(urlRuleRows, type: .urlRule),+            characterSets: characterSets(of: characterRows))     }      // MARK: - The assignment normalisation (Q38)@@ -298,6 +345,36 @@ public enum DuplicateScan {             divergentWorkSetKeysByMember: divergentWorkSetKeys(workSets))     } +    /// The character sets a set of rows implies: **application UUID only**+    /// (Q76).+    ///+    /// There is no bucket key and no union–find, because there is nothing to+    /// join. A component is one UUID's rows, and it is a set only when that UUID+    /// names more than one of them — a split group. Two distinct-UUID characters+    /// therefore never form a set, which is Req 6.4 stated as a structure rather+    /// than as a rule some later pass has to remember.+    static func characterSets(of rows: [CharacterRecord]) -> [CharacterDuplicateSet] {+        Dictionary(grouping: rows, by: \.id)+            .compactMap { id, rows -> CharacterDuplicateSet? in+                guard rows.count > 1 else { return nil }+                let sorted = GroupOrdering.sortedCharacterRows(rows)+                let member = self.member(+                    id: id,+                    contents: sorted.map(GroupOrdering.authoredContent(of:)),+                    earliest: sorted.map(\.createdAt),+                    latest: sorted.map(\.modifiedAt))+                return DuplicateSet(+                    key: DuplicateSetKey(recordType: .character, memberIDs: [id]),+                    members: [member],+                    variants: member.variants,+                    // A one-member set is never deferred: deferral is about an+                    // Entry set's assignment target, and a character has none.+                    classification: member.variants.count > 1+                        ? .divergent : .silentlyResolvable)+            }+            .sorted { $0.key < $1.key }+    }+     /// The Work sets a set of rows implies, derived by the same bucketing,     /// union–find and classification `run` uses.     static func workSets(of works: [Work], types: WorkTypeDirectory) -> [WorkDuplicateSet] {
Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift Modified +26 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swiftindex 612ffe0..8110939 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift@@ -155,7 +155,9 @@ public struct DuplicateWorkload: Sendable, Equatable {     /// spelling here would let a set be resolved silently and listed for the     /// reader in the same breath.     public init(scan: DuplicateScanResult) {-        self.init(entrySets: scan.entrySets, workSets: scan.workSets)+        self.init(+            entrySets: scan.entrySets, workSets: scan.workSets,+            characterSets: scan.characterSets)     }      /// The same, for a caller that derived the two record types' sets from rows@@ -167,7 +169,11 @@ public struct DuplicateWorkload: Sendable, Equatable {     /// workload therefore has no reason to walk the two rule tables, and the     /// Recent publication — which runs on a 2 s budget — used to walk them     /// twice per publication for nothing.-    public init(entrySets: [EntryDuplicateSet], workSets: [WorkDuplicateSet]) {+    public init(+        entrySets: [EntryDuplicateSet],+        workSets: [WorkDuplicateSet],+        characterSets: [CharacterDuplicateSet] = []+    ) {         var review: [DuplicateReviewItem] = []         var deferred: [DuplicateReviewItem] = [] @@ -200,6 +206,24 @@ public struct DuplicateWorkload: Sendable, Equatable {             }         } +        // Characters are reader-authored, so a divergent set is the reader's+        // work and belongs here. Its route is always `.sheet`: a character set+        // has one member (Q76), so there is nothing for Merge to merge, and a+        // divergent one is by construction a torn group.+        for set in characterSets {+            switch set.classification {+            case .silentlyResolvable:+                continue+            case .divergent:+                review.append(Self.item(set, route: .sheet))+            case .deferred(let blocking):+                // Unreachable — a character set has no assignment to defer+                // behind — and an arm rather than a `default` for the same+                // reason the Work loop states.+                deferred.append(Self.item(set, route: .blockedByWorkSet(blocking)))+            }+        }+         // Rule groups never appear: they carry no reader-authored fields, so         // they are always bare, never torn, and always silently resolvable         // (Q39). Iterating them here would list work the reader cannot do.
Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift Modified +6 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swiftindex 1db715b..9e8153f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/GroupOrdering.swift@@ -525,7 +525,7 @@ public enum GroupOrdering {     /// deterministic and cannot collide with a decodable rule's encoding.     public static func canonicalDefinition(_ rule: URLRulePattern) -> String {         let encoder = JSONEncoder()-        encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes]+        encoder.outputFormatting = .canonical         guard             let definition = try? JSONDecoder().decode(                 URLRuleDefinition.self, from: rule.definitionData),@@ -731,7 +731,10 @@ public enum GroupOrdering {     /// whatever `sorted(by:)` happens to do; precomputed because the keys read     /// relationships and re-deriving them per comparison would fault O(n log n)     /// times.-    private static func stableSorted<Element>(+    // Internal rather than private: the character ordering lives beside the+    // character group machinery (`CharacterGroups.swift`) and needs the same two+    // primitives every other record type orders through.+    static func stableSorted<Element>(         _ elements: [Element], key: (Element) -> [OrderComponent]     ) -> [Element] {         guard elements.count > 1 else { return elements }@@ -747,7 +750,7 @@ public enum GroupOrdering {             .map(\.element)     } -    private static func least<Element>(+    static func least<Element>(         _ elements: [Element], key: (Element) -> [OrderComponent]     ) -> Element? {         var best: (key: [OrderComponent], element: Element)?
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +37 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex ee9a295..4fd04fd 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -25,6 +25,43 @@ public protocol LibraryProviding: Sendable {     /// those.     func ruleSuggestionCandidates(hostnames: Set<String>?) async throws -> [RuleSuggestionCandidate] +    // MARK: - Character extraction (`character-extraction`)++    /// The whole input of one extraction pass's filter, per work, from one+    /// locked read (Req 1.1, Q78): source fingerprints and their coverage, the+    /// work's characters as match targets, its accepted fact identities and its+    /// active suppressions.+    ///+    /// On the protocol because the coordinator holds `any LibraryProviding` —+    /// the same reason `ruleSuggestionCandidates` is here. `workIDs` nil reads+    /// the whole library newest-activity-first for the sweep; a set reads only+    /// those, which is `reconcile()`'s pass and the manual pass's single work.+    func characterExtractionCandidates(+        limit: Int, workIDs: Set<UUID>?+    ) async throws -> [CharacterExtractionCandidate]++    /// Marks source revisions covered with no content decision behind them: the+    /// produced-none case, and the manual pass's identical advance (Req 4.3,+    /// Q65). Coverage never regresses — a fingerprint that no longer describes+    /// the source's text is dropped rather than written.+    @discardableResult+    func advanceCharacterCoverage(+        workID: UUID, sources: [CharacterCompletedSource]+    ) async throws -> Int++    /// Commits one review-list decision in one save, re-verifying staleness, the+    /// displayed match and tornness inside the transaction (Reqs 2.2, 2.7, 2.8).+    func commitCharacterDecision(+        _ request: CharacterDecisionRequest+    ) async throws -> CharacterDecisionOutcome++    /// Commits an edit session's staged character operations in one save, in the+    /// order the reader performed them, against per-character bases (Reqs 3.2,+    /// 3.3, 3.7, 5.3).+    func commitCharacterEdits(+        workID: UUID, operations: [CharacterEditOperation]+    ) async throws -> CharacterEditOutcome+     /// The configured work-type list for the settings screen and the editor's     /// picker: active entries, plus the removed ones works still use (Reqs 1.1,     /// 1.7). One row per identity — duplicate rows are folded, merged entries
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift Modified +44 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swiftindex 5ac1247..ccec90e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift@@ -52,6 +52,24 @@ extension LibraryRepository {             workTypes: payload.workTypes, into: context)     } +    /// The 6/7 counterpart. The six frozen arrays are 5/6's, and what this adds+    /// are the `Character` and `CharacterSuppression` rows, so the prospective+    /// graph the gate validates is the whole archive.+    ///+    /// Coverage is deliberately absent: it is a fingerprint of *reader text*+    /// validated against the live source at commit (Q81), and an in-memory+    /// graph built from the archive alone can only ever agree with itself.+    static func materializeV6Payload(+        _ payload: BackupV6Payload,+        into context: ModelContext+    ) throws {+        try materializeArchive(+            sites: payload.sites, titlePatterns: payload.titlePatterns,+            urlRules: payload.urlRules, works: payload.works, entries: payload.entries,+            workTypes: payload.workTypes, characters: payload.characters,+            suppressions: payload.suppressions, into: context)+    }+     private static func materializeArchive(         sites: [BackupV4Site],         titlePatterns: [BackupV4TitlePattern],@@ -59,6 +77,8 @@ extension LibraryRepository {         works: [some ArchiveWorkRecord],         entries: [BackupV4Entry],         workTypes: [BackupV5WorkTypeRecord],+        characters: [BackupV6Character] = [],+        suppressions: [BackupV6Suppression] = [],         into context: ModelContext     ) throws {         var sitesByHostname: [String: Site] = [:]@@ -144,5 +164,29 @@ extension LibraryRepository {             entry.site = sitesByHostname[record.hostname]             entry.work = record.workID.flatMap { worksByID[$0] }         }++        // A character whose work reference names nothing the archive carries+        // lands unattached — the tolerated in-flight state of Req 6.7, and the+        // shape the wire validator lets through as an orphan (Q78).+        for record in characters {+            let character = CharacterRecord(+                id: record.id, name: record.name, nameKey: record.nameKey,+                aliases: record.aliases, note: record.note, facts: record.facts,+                timestamp: record.createdAt)+            character.modifiedAt = record.modifiedAt+            context.insert(character)+            character.work = record.workID.flatMap { worksByID[$0] }+        }++        for record in suppressions {+            let row = CharacterSuppression(+                id: record.id, kind: record.kind, nameKey: record.nameKey,+                source: record.source, evidence: record.evidence, status: record.status,+                actionAt: record.actionAt)+            row.kindRaw = record.kindRaw+            row.statusRaw = record.statusRaw+            context.insert(row)+            row.work = record.workID.flatMap { worksByID[$0] }+        }     } }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift Modified +15 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swiftindex d1a6200..c7aa2de 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift@@ -33,6 +33,20 @@ extension LibraryRepository {         }     } +    /// The 6/7 gate. Same strictness again; what the graph gains is the+    /// characters, their suppressions and their coverage, so a payload whose+    /// character records contradict the schema fails here rather than at the+    /// commit. Coverage is *not* applied by the materializer — a prospective+    /// graph has no reader text to validate a fingerprint against, and Q81's+    /// rule is a commit-time one.+    static func validateImportPlanPayloadV6(+        _ payload: BackupV6Payload+    ) throws -> LibraryRecordCounts {+        try validateImportPlanGraph { context in+            try materializeV6Payload(payload, into: context)+        }+    }+     private static func validateImportPlanGraph(         materialize: (ModelContext) throws -> Void     ) throws -> LibraryRecordCounts {@@ -40,7 +54,7 @@ extension LibraryRepository {         // pinning is deliberate rather than incidental: the materializers insert         // live classes, so validating against any snapshot schema would validate         // against different entities (Q20).-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             schema: schema,             isStoredInMemoryOnly: true,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift Modified +41 / -28
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex 5685388..9f893aa 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -193,16 +193,22 @@ public extension LibraryRepository {             hooks.certificationContainerObserver?(container)             return Certification(result: .ready(counts), diagnostics: diagnostics) -        case .markerLaggingV5:-            // open (which converts, V5 → V6) → validate → publish `"6"` → clear-            // residual evidence. No data pass: the relationship pass ran at this-            // library's own certification, and the schema step is the-            // `.lightweight` stage the container construction above just-            // performed (Q26, Q37).+        case .markerLaggingV5, .markerLaggingV6:+            // open (which converts, V5 → V6 → V7) → validate → publish `"7"` →+            // clear residual evidence. No data pass: the relationship pass ran at+            // this library's own certification, and the schema steps are the+            // `.lightweight` stages the container construction above just+            // performed (Q26, Q37, Q80).+            //+            // One arm for both generations because they owe the same thing. The+            // states stay distinct in `BootstrapState` so the classifier keeps+            // saying which generation a library is on — that is what a+            // diagnostic reads — and so adding a data pass to one of them later+            // is an edit here rather than a re-derivation of the state.             //             // The marker still goes last, for the same reason as the `"4"`-            // branch: a validation failure leaves `"5"` in place, the extension-            // keeps declining, and the next launch retries.+            // branch: a validation failure leaves the old digit in place, the+            // extension keeps declining, and the next launch retries.             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             let diagnostics = try runPassAndCertify(@@ -381,10 +387,10 @@ extension LibraryRepository {         var quarantined: [String: LibraryValidationError] { diagnostics.quarantineMap() }     } -    /// Opens the fixed-path store with the live V6 schema and-    /// `AsterismV6MigrationPlan`, which declares `[V5, V6]` and one lightweight-    /// stage: this call is where an installed V5 library is converted, and the-    /// only place it happens.+    /// Opens the fixed-path store with the live V7 schema and+    /// `AsterismV7MigrationPlan`, which declares `[V5, V6, V7]` and two+    /// lightweight stages: this call is where an installed V5 or V6 library is+    /// converted, and the only place it happens.     ///     /// A store recorded below V5 has no stage and is refused here — `classify`     /// already refuses one before any container is constructed (Req 2.9,@@ -402,12 +408,12 @@ extension LibraryRepository {         at storeURL: URL,         mirroring cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .none     ) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let storeConfiguration = ModelConfiguration(             // **Frozen persisted state (Req 3.5).** This names the store             // configuration *inside* the container, not the file — `url:` below is-            // the locator. It is frozen anyway (Q13): the store it labels holds V6,-            // so the name is three versions behind, and renaming it buys nothing on+            // the locator. It is frozen anyway (Q13): the store it labels holds V7,+            // so the name is four versions behind, and renaming it buys nothing on             // a path that opens the owner's only library.             "AsterismV3",             schema: schema,@@ -416,7 +422,7 @@ extension LibraryRepository {         )         return try ModelContainer(             for: schema,-            migrationPlan: AsterismV6MigrationPlan.self,+            migrationPlan: AsterismV7MigrationPlan.self,             configurations: [storeConfiguration]         )     }@@ -424,8 +430,8 @@ extension LibraryRepository {     /// Publishes readiness at the current marker generation — the only version     /// the share extension opens, and the only version production publishes     /// (Q32): every certification path has either run the relationship pass or-    /// established it has nothing to do, so `"4"` and `"5"` markers can only come-    /// from earlier builds' libraries and the app republishes here.+    /// established it has nothing to do, so `"4"`, `"5"` and `"6"` markers can+    /// only come from earlier builds' libraries and the app republishes here.     ///     /// Unversioned by name on purpose: it always writes the generation the build     /// certifies at, and the digit has moved twice already.@@ -498,7 +504,7 @@ extension LibraryRepository {     /// | State | `sitePass` | `publishMarker` |     /// |---|---|---|     /// | `.markerLaggingV4` | true | true |-    /// | `.markerLaggingV5` | false | true |+    /// | `.markerLaggingV5`, `.markerLaggingV6` | false | true |     /// | `.ready` | false | false |     ///     /// `sitePass` is false wherever the marker already reports the relationships@@ -564,14 +570,15 @@ extension LibraryRepository {      /// Marker generations the app opens: `"4"` is a library the relationship     /// pass has not run over yet, `"5"` one it has but whose marker predates-    /// `configurable-work-types`, `"6"` a fully certified one.+    /// `configurable-work-types`, `"6"` one whose marker predates+    /// `character-extraction`, `"7"` a fully certified one.     ///     /// Every generation the project has ever published stays here. Shipping a     /// new one without extending the set fails every device still on the old     /// marker closed (`docs/agent-notes/schema-migration.md`).     static let appOpenableMarkerVersions: Set<String> = [         markerVersionAwaitingRelationshipPass, markerVersionAwaitingRepublication,-        extensionOpenableMarkerVersion,+        markerVersionAwaitingCharacterRepublication, extensionOpenableMarkerVersion,     ]      /// The marker a library carries when the relationship data pass has not run@@ -587,13 +594,19 @@ extension LibraryRepository {     /// stage `ModelContainer.init` runs. Frozen persisted state (Req 3.5).     static let markerVersionAwaitingRepublication = "5" +    /// The marker a library carries when the relationship pass has run and the+    /// marker generation predates `character-extraction`. Nothing is owed here+    /// but the republication: the V6 -> V7 step is the second `.lightweight`+    /// stage `ModelContainer.init` runs. Frozen persisted state (Req 3.5).+    static let markerVersionAwaitingCharacterRepublication = "6"+     /// The only version the share extension opens (Q14). Frozen persisted state     /// (Req 3.5).-    static let extensionOpenableMarkerVersion = "6"+    static let extensionOpenableMarkerVersion = "7"      /// App side: the marker must declare a version the app opens. The upgrade-    /// paths exist precisely for libraries still at `"4"` and `"5"`, so demanding-    /// `"6"` here would throw on every library they exist for. A version outside+    /// paths exist precisely for libraries still at `"4"`, `"5"` and `"6"`, so+    /// demanding `"7"` here would throw on every library they exist for. A version outside     /// the set, or an unreadable marker, fails closed. Returns the validated     /// version so the bootstrap can tell the lagging generations apart from a     /// certified one (Q31).@@ -611,16 +624,16 @@ extension LibraryRepository {     /// Extension side: only a migrated library opens (Q14, Req 2.3).     ///     /// `openContainer` is shared with the app, so `ModelContainer.init`-    /// performs the lightweight conversion in whichever process opens first.+    /// performs the lightweight conversions in whichever process opens first.     /// The `flock`-based lease *does* serialise the extension against a     /// migration in progress — the app holds `LOCK_EX` while `openForApp`     /// runs — but only for as long as it is held. Once the app returns, two     /// extension invocations can hold `LOCK_SH` concurrently and both attempt     /// the conversion, and the extension can be invoked when the app is not     /// running at all. Refusing here, before any container is constructed, is-    /// what keeps the conversion in the app. Accepting `"4"` or `"5"` as the-    /// app-side check does would defeat that entirely — and `"5"` is exactly the-    /// window between the app being updated and first launched, which+    /// what keeps the conversion in the app. Accepting `"4"`, `"5"` or `"6"` as+    /// the app-side check does would defeat that entirely — and `"6"` is exactly+    /// the window between the app being updated and first launched, which     /// `configurable-work-types` Req 8.7 requires to fail safely.     static func validateMarkerContentForExtension(at url: URL) throws {         let version = try readMarkerVersion(at: url)
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift Modified +11 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swiftindex 3200b58..d011b55 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift@@ -16,7 +16,7 @@ enum BootstrapState: Equatable, Sendable {     /// (Req 2.9, Decision 1): the migration that would raise it is gone, and the     /// recovery is the backup archive.     case belowV5(version: String)-    /// A certified library: the readiness marker records `"6"` and a store is+    /// A certified library: the readiness marker records `"7"` and a store is     /// present.     case ready     /// A library whose readiness marker records `"4"`. That is not a lagging@@ -29,6 +29,12 @@ enum BootstrapState: Equatable, Sendable {     /// performs on the way in, so there is no data pass to run — the upgrade is     /// a republished marker and nothing else.     case markerLaggingV5+    /// A library whose readiness marker records `"6"`: the relationship pass has+    /// run, and only the marker predates `character-extraction`. The V6 → V7+    /// conversion is the second lightweight stage `ModelContainer.init`+    /// performs, so as with `"5"` the upgrade is a republished marker and+    /// nothing else (Q80).+    case markerLaggingV6     /// Evidence that a library existed, with no store file of any kind to go with     /// it. Refused so the evidence survives for a restore (Req 2.6).     case orphanedEvidence(kind: EvidenceKind)@@ -70,13 +76,13 @@ extension LibraryRepository {     /// logical write (Req 2.1, 2.8).     ///     /// **The order is the specification.** The predicates overlap — a stale-    /// historical marker beside a valid `"6"` marker is a ready library with a+    /// historical marker beside a valid `"7"` marker is a ready library with a     /// leftover, not an ambiguity — and the first match wins, which is what makes     /// overlapping evidence resolvable at all (Q15). The rows, in order:     ///     /// 1. a positively below-V5 recorded version (Req 2.9)-    /// 2. marker `"6"` and a store present (Req 2.2)-    /// 3. marker `"4"` or `"5"` and a store present (Req 2.3, Q26)+    /// 2. marker `"7"` and a store present (Req 2.2)+    /// 3. marker `"4"`, `"5"` or `"6"` and a store present (Req 2.3, Q26, Q80)     /// 4. any evidence with no store present (Req 2.6)     /// 5. a store present with no readiness marker of any generation (Req 2.4)     /// 6. nothing on disk (Req 2.5)@@ -126,6 +132,7 @@ extension LibraryRepository {             if version == extensionOpenableMarkerVersion { return .ready }             if version == markerVersionAwaitingRelationshipPass { return .markerLaggingV4 }             if version == markerVersionAwaitingRepublication { return .markerLaggingV5 }+            if version == markerVersionAwaitingCharacterRepublication { return .markerLaggingV6 }         }          // 4. Evidence of a library whose store is gone. Never fabricate a
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift Added +504 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swiftnew file mode 100644index 0000000..b7aee96--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift@@ -0,0 +1,504 @@+import Foundation+import SwiftData++// The edit-mode half of characters (Req 3.2, 3.3, 3.7, 5.3).+//+// One repository call commits the whole session's character changes against+// per-character bases (Q73): staged operations apply **in the order the reader+// performed them** (Q97), and any basis mismatch refuses the whole step naming+// the character, leaving the editor open. Guard-and-stay is `commitEditing()`'s+// existing shape, and a partial character commit would be unreviewable.++/// What a character looked like when the editor opened. Content-derived, so a+/// basis taken on one device compares against a row synced from another.+public struct CharacterEditBasis: Sendable, Equatable {+    public let characterID: UUID+    public let name: String+    public let note: String+    public let aliases: [String]+    /// The canonical fact bytes (Q75), so an edited-apart statement is a+    /// mismatch and a re-ordered blob is not.+    public let factsData: Data?++    public init(characterID: UUID, name: String, note: String, aliases: [String], facts: [CharacterFact]) {+        self.characterID = characterID+        self.name = name+        self.note = note+        self.aliases = aliases.sorted()+        factsData = CharacterFactCodec.encode(facts)+    }++    internal init(characterID: UUID, content: CharacterAuthoredContent) {+        self.characterID = characterID+        name = content.name+        note = content.note+        aliases = content.aliases+        factsData = content.factsData+    }++    internal func matches(_ content: CharacterAuthoredContent) -> Bool {+        name == content.name && note == content.note && aliases == content.aliases+            && factsData == content.factsData+    }+}++/// The reader's intended state for one character.+///+/// Facts carry their quotes because a quote is immutable (Q74): the draft's+/// statements are applied to the facts whose identity triples match, and a fact+/// the draft omits is a deletion, whose triple is suppressed (Req 3.3).+public struct CharacterDraft: Sendable, Equatable {+    public var name: String+    public var note: String+    public var aliases: [String]+    public var facts: [CharacterFact]++    public init(+        name: String, note: String = "", aliases: [String] = [], facts: [CharacterFact] = []+    ) {+        self.name = name+        self.note = note+        self.aliases = aliases+        self.facts = facts+    }+}++/// One staged edit-session operation.+public enum CharacterEditOperation: Sendable, Equatable {+    /// Hand-creation (Q39/Q43). The key is minted from the typed name at+    /// **commit** and retained thereafter (Q46), and creating clears a standing+    /// suppression of that key (Q44).+    case create(CharacterDraft)+    case update(basis: CharacterEditBasis, draft: CharacterDraft)+    /// Deletion suppresses the retained, current and alias keys and every+    /// deleted fact's triple (Q50, Req 3.3).+    case delete(basis: CharacterEditBasis)+    /// Combine (Decision 4). The target keeps its name, retained key and UUID;+    /// the source's match keys become the target's aliases, its facts move+    /// re-keyed, its active fact suppressions re-key with them, and its note is+    /// appended under a divider. **No new suppressions**: the point of combining+    /// is that the source's name keeps attracting facts, now to the right+    /// record.+    case combine(source: CharacterEditBasis, target: CharacterEditBasis)+}++public enum CharacterEditRefusal: Sendable, Equatable {+    /// Q73: the whole step refuses and the editor stays, naming the character.+    case basisMismatch(characterID: UUID, name: String)+    /// Req 2.8/5.3: a torn character is read-only until its resolution.+    case torn(characterID: UUID, name: String)+    /// Req 5.3, Q104: the *work* is torn — the edit mode's existing read-only+    /// gate, re-checked at commit because a tear can sync in while the editor is+    /// open.+    case workTorn+    case characterGone(characterID: UUID)+    case workGone+}++public enum CharacterEditOutcome: Sendable, Equatable {+    /// The characters the step wrote or created, in the order the operations+    /// were performed.+    case committed(characterIDs: [UUID])+    case refused(CharacterEditRefusal)+}++/// The divider a combine appends the source's note under.+public enum CharacterNoteAppend {+    public static let divider = "\n\n———\n"++    public static func append(_ source: String, to target: String) -> String {+        guard !source.isEmpty else { return target }+        guard !target.isEmpty else { return source }+        return target + divider + source+    }+}++extension LibraryRepository {++    /// Commits an edit session's character operations, in one save.+    ///+    /// Every write fans out across the whole identity group (Req 2.7's rule,+    /// Q85): writing one row of a group changes its authored bytes while its+    /// siblings keep the old ones, which tears the group on the strength of an+    /// edit the reader made once.+    ///+    /// The work's own tornness is re-verified **inside the transaction**, the+    /// way `commitCharacterDecision` does it (Q104): Req 5.3's read-only gate+    /// has to hold at commit time, and a tear can sync in while the editor sits+    /// open. A torn work refuses the whole step, not the operation that noticed.+    public func commitCharacterEdits(+        workID: UUID, operations: [CharacterEditOperation]+    ) async throws -> CharacterEditOutcome {+        guard !operations.isEmpty else { return .committed(characterIDs: []) }+        return try await withLockedContext(+            mode: .exclusive, operation: "committing character edits"+        ) { context in+            let workRows = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            guard !workRows.isEmpty else { return .refused(.workGone) }+            let types = try Self.workTypeDirectory(context: context)+            guard let workGroup = Self.workGroup(id: workID, rows: workRows, types: types)+            else { return .refused(.workGone) }+            if workGroup.isTorn { return .refused(.workTorn) }++            var groups = Self.characterGroups(Self.characterRows(of: workRows))+            var suppressionRows = Self.characterSuppressionRows(of: workRows)+            let timestamp = MillisecondInstant.quantize(self.clock.now())+            var written: [UUID] = []+            // Q108: which characters this step has already written to. A basis is+            // verified on **first touch only**; after that the step trusts its+            // own transaction rather than the load-time snapshot.+            var touched: Set<UUID> = []++            // In the order performed (Q97): a combine followed by an edit of the+            // target must see the combined record, and an edit followed by a+            // delete must not resurrect it.+            for operation in operations {+                let result = Self.apply(+                    operation, groups: &groups, suppressionRows: &suppressionRows,+                    touched: &touched, workRows: workRows, timestamp: timestamp, context: context)+                switch result {+                case .refused(let refusal):+                    // The whole step, not this operation: a partial character+                    // commit would be unreviewable (Q73).+                    context.rollback()+                    return .refused(refusal)+                case .committed(let ids):+                    written += ids+                }+            }++            try self.saveStrategy.save(context)+            return .committed(characterIDs: written)+        }+    }++    /// Q108: verifies a basis on **first touch only**, and reports whether this+    /// was that first touch.+    ///+    /// The check exists to catch a change made somewhere *else*, not to catch the+    /// step's own writes. Verifying every operation against the load-time basis+    /// made combine-then-edit structurally unable to commit — the combine's own+    /// alias and fact writes moved the target's content out from under the update+    /// the editor derives from the same session's draft — and the refusal blamed+    /// a concurrent editor who did not exist.+    private static func verifyOnFirstTouch(+        _ basis: CharacterEditBasis, against group: CharacterGroup, touched: inout Set<UUID>+    ) -> (isFirstTouch: Bool, refusal: CharacterEditRefusal?) {+        guard touched.insert(group.id).inserted else { return (false, nil) }+        guard basis.matches(group.presentedContent) else {+            return (true, .basisMismatch(characterID: group.id, name: basis.name))+        }+        return (true, nil)+    }++    private static func apply(+        _ operation: CharacterEditOperation,+        groups: inout [UUID: CharacterGroup],+        suppressionRows: inout [CharacterSuppression],+        touched: inout Set<UUID>,+        workRows: [Work],+        timestamp: Date,+        context: ModelContext+    ) -> CharacterEditOutcome {+        switch operation {+        case .create(let draft):+            let key = CharacterNameKey.normalize(draft.name)+            let character = CharacterRecord(+                name: draft.name, nameKey: key, aliases: draft.aliases, note: draft.note,+                facts: draft.facts, timestamp: timestamp)+            context.insert(character)+            character.work = workRows.first+            if let group = characterGroup(id: character.id, rows: [character]) {+                groups[character.id] = group+            }+            // A row this step minted has no basis to verify against, and a later+            // operation editing it must not be handed one (Q108).+            touched.insert(character.id)+            // Q44: a re-created character must not be frozen out of enrichment+            // by the suppression its deletion wrote. Only its own typed name's+            // key clears — nothing links the aliases of a proposal that is gone.+            clearCandidateSuppression(+                key: key, workRows: workRows, suppressionRows: &suppressionRows,+                timestamp: timestamp, context: context)+            return .committed(characterIDs: [character.id])++        case .update(let basis, let draft):+            guard let group = groups[basis.characterID] else {+                return .refused(.characterGone(characterID: basis.characterID))+            }+            if group.isTorn {+                return .refused(.torn(characterID: group.id, name: basis.name))+            }+            let (isFirstTouch, refusal) = verifyOnFirstTouch(+                basis, against: group, touched: &touched)+            if let refusal { return .refused(refusal) }++            let current = group.presentedContent+            let stored = current.facts+            let kept = applyStatements(+                draft.facts, to: stored, key: group.carrier.nameKey,+                deletingOmitted: isFirstTouch)+            // Req 3.3: a fact the draft dropped is deleted, and its triple is+            // suppressed — for every stored copy of it (Q98).+            let removed = stored.filter { fact in+                !kept.contains { $0.identity == fact.identity }+            }+            // Q108's other half: on a character this step already touched, the+            // draft's *changes* are applied over the intermediate state rather+            // than its whole content being written over it. The draft describes+            // the character as it stood at load, so writing it wholesale would+            // undo the combine that ran a moment ago — its appended note and its+            // absorbed alias keys are not the reader's to discard by not having+            // seen them. On a first touch the basis has just been verified equal+            // to `current`, so every arm below resolves to the draft's value and+            // the behaviour is unchanged.+            let name = isFirstTouch || draft.name != basis.name ? draft.name : current.name+            let note = isFirstTouch || draft.note != basis.note ? draft.note : current.note+            let aliases = isFirstTouch || draft.aliases.sorted() != basis.aliases+                ? draft.aliases : current.aliases+            write(+                group, name: name, note: note, aliases: aliases,+                facts: kept, timestamp: timestamp)+            suppress(+                facts: removed.map(\.identity), workRows: workRows,+                suppressionRows: &suppressionRows, timestamp: timestamp, context: context)+            groups[group.id] = characterGroup(id: group.id, rows: group.rows)+            return .committed(characterIDs: [group.id])++        case .delete(let basis):+            guard let group = groups[basis.characterID] else {+                return .refused(.characterGone(characterID: basis.characterID))+            }+            if group.isTorn {+                return .refused(.torn(characterID: group.id, name: basis.name))+            }+            if let refusal = verifyOnFirstTouch(+                basis, against: group, touched: &touched).refusal {+                return .refused(refusal)+            }+            // Q50: retained, current *and* alias keys. A rename-then-delete+            // would otherwise re-propose the character under the deleted name,+            // and after Decision 4 the aliases own absorbed names' routing and+            // must die with the record.+            let keys = Set(+                [group.carrier.nameKey, CharacterNameKey.normalize(group.presentedContent.name)]+                    + group.presentedContent.aliases.map(CharacterNameKey.normalize))+            for key in keys where !key.isEmpty {+                writeSuppression(+                    kind: .candidate, nameKey: key, source: nil, evidence: nil, status: .active,+                    workRows: workRows, suppressionRows: &suppressionRows, timestamp: timestamp,+                    context: context)+            }+            suppress(+                facts: group.presentedContent.facts.map(\.identity), workRows: workRows,+                suppressionRows: &suppressionRows, timestamp: timestamp, context: context)+            for row in group.rows { context.delete(row) }+            groups[group.id] = nil+            return .committed(characterIDs: [group.id])++        case .combine(let sourceBasis, let targetBasis):+            guard let source = groups[sourceBasis.characterID] else {+                return .refused(.characterGone(characterID: sourceBasis.characterID))+            }+            guard let target = groups[targetBasis.characterID] else {+                return .refused(.characterGone(characterID: targetBasis.characterID))+            }+            // Torn gates both sides: a combine into or out of a torn record+            // would deepen the tear it is meant to leave alone.+            for (group, basis) in [(source, sourceBasis), (target, targetBasis)]+            where group.isTorn {+                return .refused(.torn(characterID: group.id, name: basis.name))+            }+            // Q108, on both sides: verified against the load-time basis only+            // where this step has not already written to the character.+            if let refusal = verifyOnFirstTouch(+                sourceBasis, against: source, touched: &touched).refusal {+                return .refused(refusal)+            }+            if let refusal = verifyOnFirstTouch(+                targetBasis, against: target, touched: &touched).refusal {+                return .refused(refusal)+            }+            combine(+                source: source, into: target, workRows: workRows,+                suppressionRows: &suppressionRows, timestamp: timestamp, context: context)+            groups[source.id] = nil+            groups[target.id] = characterGroup(id: target.id, rows: target.rows)+            return .committed(characterIDs: [target.id])+        }+    }++    // MARK: - Combine (Decision 4)++    private static func combine(+        source: CharacterGroup,+        into target: CharacterGroup,+        workRows: [Work],+        suppressionRows: inout [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        let sourceContent = source.presentedContent+        let targetContent = target.presentedContent+        let targetKey = target.carrier.nameKey++        // Q91: the source's **match keys**, not its display strings — current+        // name, retained key (stored as a bare key string where no display form+        // survives a rename), and aliases — deduped against the target's own+        // keys. A union of display strings alone would drop a renamed source's+        // retained key and re-manufacture the duplicate the combine fixes.+        var aliases = targetContent.aliases+        var taken = Set(+            aliases.map(CharacterNameKey.normalize)+                + [CharacterNameKey.normalize(targetContent.name), targetKey])+        for candidate in [sourceContent.name] + sourceContent.aliases + [source.carrier.nameKey] {+            let key = CharacterNameKey.normalize(candidate)+            guard !key.isEmpty, taken.insert(key).inserted else { continue }+            aliases.append(candidate)+        }++        // Facts move re-keyed to the target's retained key (Q79). An identity+        // duplicate drops — **except** where the statements were edited apart,+        // in which case both copies survive (Q94), which is why the canonical+        // order breaks its tie on the statement (Q98).+        var facts = targetContent.facts+        for fact in sourceContent.facts.map({ $0.rekeyed(to: targetKey) }) {+            let sameTriple = facts.filter { $0.identity == fact.identity }+            guard !sameTriple.contains(where: { $0.statement == fact.statement }) else { continue }+            facts.append(fact)+        }++        write(+            target, name: targetContent.name, note: CharacterNoteAppend.append(+                sourceContent.note, to: targetContent.note),+            aliases: aliases, facts: facts, timestamp: timestamp)++        // Q94: the source's active fact suppressions re-key to the target, in+        // the same transaction. Orphaned source-keyed rows would resurrect+        // unticked facts the next time a pass proposed them.+        let sourceKey = source.carrier.nameKey+        for row in suppressionRows+        where row.kind == .fact && row.nameKey == sourceKey && row.status == .active {+            guard let sourceRef = row.source, let evidence = row.evidence else { continue }+            writeSuppression(+                kind: .fact, nameKey: targetKey, source: sourceRef, evidence: evidence,+                status: .active, workRows: workRows, suppressionRows: &suppressionRows,+                timestamp: timestamp, context: context)+            // The source-keyed row is cleared rather than deleted, for the same+            // reason a clear is never a deletion (Q52): a deleted row resurrects+            // under sync.+            row.status = .cleared+            row.actionAt = timestamp+        }++        // The source group deletes **whole** (the work-merge rule): a proper+        // subset left behind is the partial combine the fan-out rule exists to+        // prevent. No suppression is written for it — Decision 4's whole point.+        for row in source.rows { context.delete(row) }+    }++    // MARK: - Writing++    /// Q74: the quote is immutable, so a draft can only move statements. A draft+    /// fact whose triple is not stored is ignored rather than inserted: the edit+    /// surface has no way to author evidence.+    ///+    /// `deletingOmitted` is Q108 again: a fact the draft does not mention is a+    /// deletion (Req 3.3) only where the draft was taken over the same stored+    /// set. On a character an earlier operation in this step already wrote to,+    /// the unmentioned facts are the ones that operation moved across, and the+    /// draft's silence about them says nothing.+    private static func applyStatements(+        _ draft: [CharacterFact], to stored: [CharacterFact], key: String,+        deletingOmitted: Bool+    ) -> [CharacterFact] {+        var statements: [CharacterFactIdentity: String] = [:]+        for fact in draft { statements[fact.rekeyed(to: key).identity] = fact.statement }+        return stored.compactMap { fact in+            guard let statement = statements[fact.identity] else {+                return deletingOmitted ? nil : fact+            }+            return CharacterFact(+                statement: statement, quote: fact.quote, nameKey: fact.nameKey,+                source: fact.source)+        }+    }++    /// One write, fanned out across every row of the group (Q85's rule).+    private static func write(+        _ group: CharacterGroup,+        name: String,+        note: String,+        aliases: [String],+        facts: [CharacterFact],+        timestamp: Date+    ) {+        let bytes = CharacterFactCodec.encode(facts)+        for row in group.rows {+            row.name = name+            row.note = note+            row.aliases = aliases+            row.factsData = bytes+            row.modifiedAt = timestamp+        }+    }++    // MARK: - Suppression helpers (the inout twins of the extraction ones)++    private static func suppress(+        facts identities: [CharacterFactIdentity],+        workRows: [Work],+        suppressionRows: inout [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        for identity in identities {+            writeSuppression(+                kind: .fact, nameKey: identity.nameKey, source: identity.source,+                evidence: identity.quote, status: .active, workRows: workRows,+                suppressionRows: &suppressionRows, timestamp: timestamp, context: context)+        }+    }++    private static func clearCandidateSuppression(+        key: String,+        workRows: [Work],+        suppressionRows: inout [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        guard !key.isEmpty else { return }+        let existing = suppressionRows.filter { $0.kind == .candidate && $0.nameKey == key }+        guard !existing.isEmpty else { return }+        writeSuppression(+            kind: .candidate, nameKey: key, source: nil, evidence: nil, status: .cleared,+            workRows: workRows, suppressionRows: &suppressionRows, timestamp: timestamp,+            context: context)+    }++    /// Q82's in-place write — **the extraction path's**, over a row list this+    /// call keeps up to date so a later operation in the same step sees what an+    /// earlier one wrote.+    ///+    /// The keying lives in one place (`LibraryRepository+CharacterExtraction`):+    /// two spellings of "the same suppression" would let an edit-session write+    /// and a decision write miss each other's rows.+    private static func writeSuppression(+        kind: CharacterSuppressionKind,+        nameKey: String,+        source: SourceRef?,+        evidence: String?,+        status: CharacterSuppressionStatus,+        workRows: [Work],+        suppressionRows: inout [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        let inserted = writeSuppression(+            kind: kind, nameKey: nameKey, source: source, evidence: evidence, status: status,+            workRows: workRows, suppressionRows: suppressionRows, timestamp: timestamp,+            context: context)+        if let inserted { suppressionRows.append(inserted) }+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift Added +658 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swiftnew file mode 100644index 0000000..7be41e5--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift@@ -0,0 +1,658 @@+import Foundation+import SwiftData++// The store half of character extraction: one read that supplies the sweep's+// whole filter input (Q78), and one commit per reader decision (Q37).+//+// **Coverage-write ownership is split on purpose** (Q65): source completeness is+// the coordinator's knowledge — it is what the grounding and filtering left —+// and the write is the repository's transaction. A source emptied by filtering+// covers at pass time through `advanceCharacterCoverage`; a source with shown+// proposals covers inside `commitCharacterDecision`, in the same save as the+// content the decision accepted. A crash between decisions loses only the+// coverage advance: the next sweep re-derives, the filter empties it, and+// produced-none covers it.++extension LibraryRepository {++    // MARK: - The candidate read++    /// Every work the sweep might process, newest note activity first.+    ///+    /// One locked context, and everything the dedup filter needs comes out of+    /// it: the source fingerprints, the stored coverage, the accepted fact+    /// identities, the active suppressions and the existing characters' match+    /// keys. Reading any of them separately would let the filter compare state+    /// from two different moments (Q78).+    ///+    /// **Torn works are excluded** (Q53): their proposals could not be accepted+    /// (Req 2.8) and would die undecided on restart, so processing one spends+    /// model time for nothing.+    public func characterExtractionCandidates(+        limit: Int, workIDs: Set<UUID>? = nil+    ) async throws -> [CharacterExtractionCandidate] {+        if let workIDs, workIDs.isEmpty { return [] }+        return try await withLockedContext(+            mode: .shared, operation: "reading character extraction candidates"+        ) { context in+            let works: [Work]+            if let workIDs {+                let wanted = Array(workIDs)+                works = try context.fetch(+                    FetchDescriptor<Work>(predicate: #Predicate { wanted.contains($0.id) }))+            } else {+                works = try context.fetch(FetchDescriptor<Work>())+            }+            let types = try Self.workTypeDirectory(context: context)+            // The unscoped sweep read stays whole-library on both tables: Q78+            // pins that breadth, and a sweep already holds every work.+            //+            // A **scoped** read is a different question — one work, asked by the+            // manual pass and by `reconcile` — and there the two whole-table+            // fetches scaled with the library rather than with the work. The+            // rows are reachable from the works already fetched, through the+            // inverses `CharacterCitationRepointing.repoint(in:)` walks, and an+            // orphan (Req 6.7) is excluded either way: grouping by `work?.id`+            // filed it under nil, and the inverse walk never reaches it.+            var charactersByWork: [UUID?: [CharacterRecord]] = [:]+            var suppressionsByWork: [UUID?: [CharacterSuppression]] = [:]+            if workIDs == nil {+                charactersByWork = Dictionary(+                    grouping: try context.fetch(FetchDescriptor<CharacterRecord>())) {+                    $0.work?.id+                }+                suppressionsByWork = Dictionary(+                    grouping: try context.fetch(FetchDescriptor<CharacterSuppression>())) {+                    $0.work?.id+                }+            }++            return Self.workGroups(works, types: types).values+                .compactMap { group -> CharacterExtractionCandidate? in+                    guard !group.isTorn else { return nil }+                    return Self.extractionCandidate(+                        group,+                        characters: workIDs == nil+                            ? charactersByWork[group.id] ?? []+                            : Self.characterRows(of: group.rows),+                        suppressions: workIDs == nil+                            ? suppressionsByWork[group.id] ?? []+                            : Self.characterSuppressionRows(of: group.rows))+                }+                // Newest activity first, UUID as the tie-break so two devices+                // examine the same works in the same order.+                .sorted {+                    $0.recency == $1.recency+                        ? $0.workID.uuidString < $1.workID.uuidString+                        : $0.recency > $1.recency+                }+                .prefix(max(0, limit))+                .map { $0 }+        }+    }++    private static func extractionCandidate(+        _ group: WorkGroup,+        characters: [CharacterRecord],+        suppressions: [CharacterSuppression]+    ) -> CharacterExtractionCandidate? {+        let carrier = group.carrier+        let genericNotes = carrier.genericNotes+        var sources: [CharacterExtractionSource] = []+        var recency = Date.distantPast++        // Generic notes first — the display order Q88 pins, and the order the+        // sweep processes them in.+        if !genericNotes.isEmpty {+            sources.append(+                CharacterExtractionSource(+                    ref: .genericNotes,+                    text: genericNotes,+                    fingerprint: CharacterCoverageFingerprint.of(genericNotes),+                    coveredFingerprint: carrier.genericNotesExtractionFingerprint))+            // Q84: a generic-notes edit moves no entry timestamp, so the work's+            // own clock has to count or such a work never surfaces at all.+            recency = max(recency, group.modifiedAt)+        }++        // Entries the reader has actually noted. An empty note is not a source:+        // there is nothing to extract from it, and counting it would make every+        // capture an uncovered revision for ever.+        let noted = group.rows+            .flatMap { $0.entryValues }+            .filter { !$0.note.isEmpty }+        var seen: Set<UUID> = []+        for entry in noted.sorted(by: { $0.firstCapturedAt < $1.firstCapturedAt })+        where seen.insert(entry.id).inserted {+            sources.append(+                CharacterExtractionSource(+                    ref: .entry(entry.id),+                    text: entry.note,+                    fingerprint: CharacterCoverageFingerprint.of(entry.note),+                    coveredFingerprint: entry.characterExtractionFingerprint))+            // Q71: a note edit bumps `modifiedAt` and is exactly the event that+            // uncovers a revision; `lastSharedAt` covers a re-share.+            recency = max(recency, max(entry.modifiedAt, entry.lastSharedAt))+        }++        guard !sources.isEmpty else { return nil }++        let groups = characterGroups(characters)+        let targets = groups.values.map(matchTarget)+        var accepted: Set<CharacterFactIdentity> = []+        for group in groups.values {+            for fact in group.presentedContent.facts { accepted.insert(fact.identity) }+        }++        return CharacterExtractionCandidate(+            workID: group.id,+            displayTitle: carrier.displayTitle,+            recency: recency,+            sources: sources,+            characters: targets.sorted { $0.id.uuidString < $1.id.uuidString },+            acceptedFactIdentities: accepted,+            suppressions: suppressionIndex(suppressions))+    }++    // MARK: - Matching (Req 2.3)++    /// A character group as decision-time matching sees it — **the one+    /// mapping**, so the sweep's read and the commit's re-verification compare+    /// the same keys against the same tiers.+    internal static func matchTarget(_ group: CharacterGroup) -> CharacterMatchTarget {+        CharacterMatchTarget(+            id: group.id,+            currentNameKey: CharacterNameKey.normalize(group.presentedContent.name),+            retainedKey: group.carrier.nameKey,+            aliasKeys: group.presentedContent.aliases.map(CharacterNameKey.normalize),+            isTorn: group.isTorn)+    }++    // MARK: - The rows one work owns++    /// A work's characters, from the rows already fetched.+    ///+    /// Every row of a split work group carries its own `characters` inverse, so+    /// the union over the group's rows is the work's characters — the walk+    /// `CharacterCitationRepointing.repoint(in:)` already does, deduplicated by+    /// object identity because a character reached through two rows is one+    /// character. It replaces a whole-table fetch filtered down to one work,+    /// which scaled with the library rather than with the work.+    internal static func characterRows(of works: [Work]) -> [CharacterRecord] {+        related(works, \.characterValues)+    }++    /// The same for suppression rows. An orphan whose work has not arrived+    /// (Req 6.7) is unreachable through the inverse — exactly as the filtered+    /// fetch excluded it.+    internal static func characterSuppressionRows(of works: [Work]) -> [CharacterSuppression] {+        related(works, \.characterSuppressionValues)+    }++    private static func related<Row: AnyObject>(+        _ works: [Work], _ rows: (Work) -> [Row]+    ) -> [Row] {+        var collected: [Row] = []+        var seen: Set<ObjectIdentifier> = []+        for work in works {+            for row in rows(work) where seen.insert(ObjectIdentifier(row)).inserted {+                collected.append(row)+            }+        }+        return collected+    }++    // MARK: - Suppression convergence (Q82)++    /// The key a suppression row is written and read under. Rows duplicated by+    /// sync share it, and the reader-visible answer is the latest `actionAt`,+    /// tie-broken cleared-wins then lowest row UUID.+    private struct SuppressionKey: Hashable {+        let kind: CharacterSuppressionKind+        let nameKey: String+        let sourceKind: String?+        let sourceEntryID: UUID?+        let evidence: String?++        init(_ row: CharacterSuppression) {+            kind = row.kind+            nameKey = row.nameKey+            sourceKind = row.sourceKindRaw+            sourceEntryID = row.sourceEntryID+            evidence = row.evidence+        }++        init(+            kind: CharacterSuppressionKind, nameKey: String, source: SourceRef?, evidence: String?+        ) {+            self.kind = kind+            self.nameKey = nameKey+            sourceKind = source?.kindRaw+            sourceEntryID = source?.entryID+            self.evidence = evidence+        }+    }++    /// Q82's read-through, applied once per key.+    ///+    /// A clear must never be undone by an older suppression syncing in (Req+    /// 6.6), which is what the `actionAt` comparison buys; cleared-wins on an+    /// exact tie is the safe direction, because a suppression the reader+    /// cleared re-suppressing is a nuisance and a clear lost is a character+    /// frozen out of enrichment. The lowest row UUID settles the rest so two+    /// devices agree.+    private static func resolvedSuppressions(+        _ rows: [CharacterSuppression]+    ) -> [SuppressionKey: CharacterSuppression] {+        var winners: [SuppressionKey: CharacterSuppression] = [:]+        for row in rows {+            let key = SuppressionKey(row)+            guard let current = winners[key] else {+                winners[key] = row+                continue+            }+            if suppressionPrecedes(row, current) { winners[key] = row }+        }+        return winners+    }++    /// Whether `candidate` is the row the reader's most recent action left.+    private static func suppressionPrecedes(+        _ candidate: CharacterSuppression, _ current: CharacterSuppression+    ) -> Bool {+        if candidate.actionAt != current.actionAt { return candidate.actionAt > current.actionAt }+        if candidate.status != current.status { return candidate.status == .cleared }+        return candidate.id.uuidString < current.id.uuidString+    }++    private static func suppressionIndex(+        _ rows: [CharacterSuppression]+    ) -> CharacterSuppressionIndex {+        var candidateKeys: Set<String> = []+        var factIdentities: Set<CharacterFactIdentity> = []+        for (key, row) in resolvedSuppressions(rows) where row.status == .active {+            switch key.kind {+            case .candidate:+                candidateKeys.insert(key.nameKey)+            case .fact:+                guard let source = row.source, let evidence = row.evidence else { continue }+                factIdentities.insert(+                    CharacterFactIdentity(+                        nameKey: key.nameKey, source: source, quote: evidence))+            }+        }+        return CharacterSuppressionIndex(+            candidateKeys: candidateKeys, factIdentities: factIdentities)+    }++    // MARK: - Coverage (Q65)++    /// Marks source revisions covered without any content decision — the+    /// produced-none case, and the manual pass's identical advance.+    ///+    /// **Coverage never regresses**: a fingerprint write only ever moves to the+    /// text the source currently holds, so a stale fingerprint is dropped rather+    /// than written. Covering more is the safe direction (Q65).+    @discardableResult+    public func advanceCharacterCoverage(+        workID: UUID, sources: [CharacterCompletedSource]+    ) async throws -> Int {+        guard !sources.isEmpty else { return 0 }+        return try await withLockedContext(+            mode: .exclusive, operation: "advancing character extraction coverage"+        ) { context in+            let works = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            guard !works.isEmpty else { return 0 }+            let written = Self.writeCoverage(sources, works: works)+            if written > 0 { try self.saveStrategy.save(context) }+            return written+        }+    }++    /// Writes coverage for the sources whose fingerprint still describes the+    /// text, across every row of the work group.+    @discardableResult+    private static func writeCoverage(+        _ sources: [CharacterCompletedSource], works: [Work]+    ) -> Int {+        guard !works.isEmpty else { return 0 }+        var written = 0+        let entriesByID = Dictionary(grouping: works.flatMap { $0.entryValues }, by: \.id)+        for source in sources {+            switch source.ref {+            case .genericNotes:+                for work in works+                where CharacterCoverageFingerprint.of(work.genericNotes) == source.fingerprint {+                    guard work.genericNotesExtractionFingerprint != source.fingerprint+                    else { continue }+                    work.genericNotesExtractionFingerprint = source.fingerprint+                    written += 1+                }+            case .entry(let id):+                for entry in entriesByID[id] ?? []+                where CharacterCoverageFingerprint.of(entry.note) == source.fingerprint {+                    guard entry.characterExtractionFingerprint != source.fingerprint+                    else { continue }+                    entry.characterExtractionFingerprint = source.fingerprint+                    written += 1+                }+            }+        }+        return written+    }++    // MARK: - Committing a decision (Req 2.2, 2.7, 2.8)++    /// Commits one review-list decision, in one save.+    ///+    /// It re-verifies three things inside the transaction, because a proposal is+    /// held in memory while the store moves underneath it:+    ///+    /// * the cited revisions still hold the text they were proposed from+    ///   (Req 2.7),+    /// * the proposal still resolves onto the character the reader was **shown**+    ///   — including a row shown as a new candidate that now matches an existing+    ///   character (Q66) — and+    /// * neither the work nor that character is torn (Req 2.8, Q48).+    ///+    /// The torn and staleness gates refuse **acceptance only**. A skip writes+    /// only system records, which never tear and never block anything (Req 6.6),+    /// and refusing it would strand Req 2.7's rule that a stale proposal still+    /// records its suppression.+    public func commitCharacterDecision(+        _ request: CharacterDecisionRequest+    ) async throws -> CharacterDecisionOutcome {+        try await withLockedContext(+            mode: .exclusive, operation: "committing a character decision"+        ) { context in+            // A local `let` rather than `request.workID` inside the predicate:+            // the macro cannot key-path into a captured struct.+            let workID = request.workID+            let workRows = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            guard !workRows.isEmpty else { return .refused(.workGone) }+            let types = try Self.workTypeDirectory(context: context)+            guard let workGroup = Self.workGroup(+                id: request.workID, rows: workRows, types: types)+            else { return .refused(.workGone) }++            let characters = Self.characterRows(of: workRows)+            let suppressionRows = Self.characterSuppressionRows(of: workRows)+            let groups = Self.characterGroups(characters)+            let timestamp = MillisecondInstant.quantize(self.clock.now())++            var committedID: UUID?+            switch request.action {+            case .accept:+                // Resolved once and carried: the gate and the write must agree+                // about which character this is, and two resolutions of one+                // proposal are two chances to disagree.+                let target = Self.resolvedTarget(request, groups: groups)+                if let refusal = Self.acceptanceRefusal(+                    request, workGroup: workGroup, resolved: target, workRows: workRows) {+                    return .refused(refusal)+                }+                committedID = try Self.applyAcceptance(+                    request, target: target, workRows: workRows,+                    suppressionRows: suppressionRows, timestamp: timestamp, context: context)+            case .skip:+                Self.applySkip(+                    request, workRows: workRows, suppressionRows: suppressionRows,+                    timestamp: timestamp, context: context)+            }++            // Unticked facts suppress on either action: a fact the reader+            // unticked inside an accepted candidate is a decision about that+            // fact (Req 2.4).+            Self.suppressFacts(+                request.untickedFacts, workRows: workRows, suppressionRows: suppressionRows,+                timestamp: timestamp, context: context)++            // Coverage rides the same save (Q65).+            Self.writeCoverage(request.completedSources, works: workRows)+            try self.saveStrategy.save(context)+            return .committed(characterID: committedID)+        }+    }++    /// The three acceptance gates, in the order the design states them.+    private static func acceptanceRefusal(+        _ request: CharacterDecisionRequest,+        workGroup: WorkGroup,+        resolved: CharacterGroup?,+        workRows: [Work]+    ) -> CharacterDecisionRefusal? {+        if workGroup.isTorn { return .torn(characterID: nil) }+        for source in request.completedSources where !currentlyHolds(source, works: workRows) {+            return .staleSource(source.ref)+        }+        guard resolved?.id == request.displayedTargetID else {+            return .reRouted(to: resolved?.id)+        }+        if let resolved, resolved.isTorn { return .torn(characterID: resolved.id) }+        return nil+    }++    /// Whether a cited revision still holds the text it was proposed from.+    private static func currentlyHolds(+        _ source: CharacterCompletedSource, works: [Work]+    ) -> Bool {+        switch source.ref {+        case .genericNotes:+            return works.contains {+                CharacterCoverageFingerprint.of($0.genericNotes) == source.fingerprint+            }+        case .entry(let id):+            let entries = works.flatMap { $0.entryValues }.filter { $0.id == id }+            // A cited entry that has gone is *not* stale: Decision 2 makes a+            // dangling citation a tolerated state, and refusing the acceptance+            // would make routine curation look like a conflict.+            guard !entries.isEmpty else { return true }+            return entries.contains {+                CharacterCoverageFingerprint.of($0.note) == source.fingerprint+            }+        }+    }++    /// Req 2.3's tiers over the work's current characters.+    ///+    /// The tiers themselves are `CharacterMatching`'s — the same body the sweep's+    /// read runs (Q99's reasoning: two spellings of a match are two devices+    /// routing one proposal onto two characters). Proposed aliases deliberately+    /// take no part (Q93): matching is on the name half only, so a split+    /// candidate whose alias half names an existing character shows as a new+    /// candidate and the combine fixes it.+    private static func resolvedTarget(+        _ request: CharacterDecisionRequest, groups: [UUID: CharacterGroup]+    ) -> CharacterGroup? {+        guard let matched = CharacterMatching.match(+            nameKey: CharacterNameKey.normalize(request.proposedName),+            among: groups.values.map(matchTarget))+        else { return nil }+        return groups[matched.id]+    }++    private static func applyAcceptance(+        _ request: CharacterDecisionRequest,+        target: CharacterGroup?,+        workRows: [Work],+        suppressionRows: [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) throws -> UUID? {+        let retainedKey = target?.carrier.nameKey+            ?? CharacterNameKey.normalize(request.proposedName)+        // Q79: facts are canonicalised to the resolved character's retained key+        // before dedup and storage, so an alias spelling of an accepted quote+        // dedups instead of re-proposing.+        let incoming = request.facts.map { $0.rekeyed(to: retainedKey) }++        let characterID: UUID+        if let target {+            characterID = target.id+            var facts = target.presentedContent.facts+            var seen = Set(facts.map(\.identity))+            for fact in incoming where seen.insert(fact.identity).inserted { facts.append(fact) }+            var aliases = target.presentedContent.aliases+            let existingKeys = Set(+                aliases.map(CharacterNameKey.normalize)+                    + [CharacterNameKey.normalize(target.presentedContent.name), retainedKey])+            for alias in request.proposedAliases+            where !existingKeys.contains(CharacterNameKey.normalize(alias)) {+                aliases.append(alias)+            }+            let bytes = CharacterFactCodec.encode(facts)+            // Every row of the group takes the write, or the group tears on the+            // strength of an acceptance the reader made once (Q85's rule).+            for row in target.rows {+                row.factsData = bytes+                row.aliases = aliases+                row.modifiedAt = timestamp+            }+        } else {+            let character = CharacterRecord(+                name: request.proposedName,+                nameKey: retainedKey,+                aliases: request.proposedAliases,+                facts: incoming,+                timestamp: timestamp)+            context.insert(character)+            character.work = workRows.first+            characterID = character.id+        }++        // Req 2.5: accepting clears the standing suppression of the keys the row+        // displayed, and of every fact it accepted.+        clearSuppressions(+            keys: request.displayedKeys, factIdentities: incoming.map(\.identity),+            workRows: workRows, suppressionRows: suppressionRows, timestamp: timestamp,+            context: context)+        return characterID+    }++    private static func applySkip(+        _ request: CharacterDecisionRequest,+        workRows: [Work],+        suppressionRows: [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        guard request.displayedTargetID != nil else {+            // Skipping a *candidate* suppresses **the keys its row displayed**+            // and nothing else (Req 2.2) — a struck alias's key is not among+            // them (Q92), and neither are the facts. The key suppression already+            // stops the candidate coming back; suppressing its triples as well+            // would freeze those facts out of a character later created under+            // the same name by hand or by combine, where the name key no longer+            // applies (Q47).+            for key in request.displayedKeys where !key.isEmpty {+                writeSuppression(+                    kind: .candidate, nameKey: key, source: nil, evidence: nil,+                    status: .active, workRows: workRows, suppressionRows: suppressionRows,+                    timestamp: timestamp, context: context)+            }+            return+        }+        // Skipping a *bundle* suppresses the affected facts and never the+        // character's name key (Q47/Req 2.4): a name-key suppression blocks new+        // candidates only, and writing one here would freeze an existing+        // character out of enrichment for ever.+        suppressFacts(+            request.facts.map(\.identity), workRows: workRows,+            suppressionRows: suppressionRows, timestamp: timestamp, context: context)+    }++    private static func suppressFacts(+        _ identities: [CharacterFactIdentity],+        workRows: [Work],+        suppressionRows: [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        for identity in identities {+            writeSuppression(+                kind: .fact, nameKey: identity.nameKey, source: identity.source,+                evidence: identity.quote, status: .active, workRows: workRows,+                suppressionRows: suppressionRows, timestamp: timestamp, context: context)+        }+    }++    private static func clearSuppressions(+        keys: [String],+        factIdentities: [CharacterFactIdentity],+        workRows: [Work],+        suppressionRows: [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) {+        for key in keys where !key.isEmpty {+            // A clear is written only where a row exists: minting a `cleared`+            // row for a key nothing ever suppressed is accretion with no reader+            // meaning.+            let wanted = SuppressionKey(+                kind: .candidate, nameKey: key, source: nil, evidence: nil)+            guard suppressionRows.contains(where: { SuppressionKey($0) == wanted }) else {+                continue+            }+            writeSuppression(+                kind: .candidate, nameKey: key, source: nil, evidence: nil, status: .cleared,+                workRows: workRows, suppressionRows: suppressionRows, timestamp: timestamp,+                context: context)+        }+        for identity in factIdentities {+            let wanted = SuppressionKey(+                kind: .fact, nameKey: identity.nameKey, source: identity.source,+                evidence: identity.quote)+            guard suppressionRows.contains(where: { SuppressionKey($0) == wanted }) else {+                continue+            }+            writeSuppression(+                kind: .fact, nameKey: identity.nameKey, source: identity.source,+                evidence: identity.quote, status: .cleared, workRows: workRows,+                suppressionRows: suppressionRows, timestamp: timestamp, context: context)+        }+    }++    /// Q82: the write **updates one existing row in place**, keyed by+    /// (work, kind, nameKey, source, evidence), and inserts only where no row of+    /// that key exists.+    ///+    /// *Which* row of the key it updates does not matter, and the lowest UUID is+    /// simply a deterministic pick: reads resolve a key to its latest `actionAt`+    /// (Q82), so stamping any row of the key with the reader's action makes that+    /// row the winner. The others are left alone and read through by+    /// `resolvedSuppressions` — deleting or rewriting them would be a second+    /// convergence rule fighting the first.+    ///+    /// Returns the row it inserted, or nil where it updated one. **The one+    /// implementation**: the edit path's `inout` twin delegates here so the two+    /// paths cannot key a suppression differently.+    @discardableResult+    static func writeSuppression(+        kind: CharacterSuppressionKind,+        nameKey: String,+        source: SourceRef?,+        evidence: String?,+        status: CharacterSuppressionStatus,+        workRows: [Work],+        suppressionRows: [CharacterSuppression],+        timestamp: Date,+        context: ModelContext+    ) -> CharacterSuppression? {+        let wanted = SuppressionKey(+            kind: kind, nameKey: nameKey, source: source, evidence: evidence)+        let existing = suppressionRows.filter { SuppressionKey($0) == wanted }+        guard let winner = existing.min(by: { $0.id.uuidString < $1.id.uuidString }) else {+            let row = CharacterSuppression(+                kind: kind, nameKey: nameKey, source: source, evidence: evidence,+                status: status, actionAt: timestamp)+            context.insert(row)+            row.work = workRows.first+            return row+        }+        winner.status = status+        winner.actionAt = timestamp+        return nil+    }+}
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift Modified +25 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex 0c0e179..2a2fa6f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -231,14 +231,16 @@ extension LibraryRepository {             grouping: try context.fetch(FetchDescriptor<Work>()), by: \.id)          // The type list is merged **before** any Work is applied (Reqs 7.3, 7.4,-        // 7.8). A 5/6 work cites its type by identifier, so the alias rows and-        // minted entries that make those citations resolve have to be in the+        // 7.8). A 5/6 or 6/7 work cites its type by identifier, so the alias rows+        // and minted entries that make those citations resolve have to be in the         // store by the time the assignment is written — otherwise every         // cross-library import would land as unresolved and heal only if sync         // happened to deliver another library's rows, which it never will.-        if case .v5Archive(let archive) = payload {+        let typeRecords = payload.workTypeRecords+        if !typeRecords.types.isEmpty || !typeRecords.works.isEmpty {             try mergeImportedWorkTypes(-                archive, exportedAt: exportedAt, importedAt: importedAt,+                workTypes: typeRecords.types, works: typeRecords.works,+                exportedAt: exportedAt, importedAt: importedAt,                 context: context, saveStrategy: saveStrategy)         } @@ -256,6 +258,11 @@ extension LibraryRepository {                 archive.works, into: &workRows, sitesByHostname: sitesByHostname,                 types: types, context: context, batchSize: batchSize,                 saveStrategy: saveStrategy)+        case .v6Archive(let archive):+            try commitWorks(+                archive.works, into: &workRows, sitesByHostname: sitesByHostname,+                types: types, context: context, batchSize: batchSize,+                saveStrategy: saveStrategy)         }          // The row an Entry's assignment *points* at. Every row of the group is@@ -309,7 +316,20 @@ extension LibraryRepository {             try saveStrategy.save(context)         } -        // (4) The rule merge. Archive rules joining existing ones collide on+        // (4) The characters, their suppressions and their coverage — after the+        // Works and Entries they attach to, so a character's work reference and+        // a coverage pair's source are both already in the store+        // (`character-extraction` Req 6.1). Only 6/7 carries them; the earlier+        // generations import with no characters created, which is the same+        // requirement's other half.+        if case .v6Archive(let archive) = payload {+            try mergeImportedCharacters(+                archive, workTargets: workTargets, workRows: workRows,+                entryRows: entryRows, context: context)+            try saveStrategy.save(context)+        }++        // (5) The rule merge. Archive rules joining existing ones collide on         // version and can leave two active title rules on one row, so the union         // renumbers deterministically and rewrites every citing record's         // `(id, version)` pair — the imported records and the ones already there
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift Modified +118 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftindex 1ef27a3..9909067 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -109,6 +109,12 @@ extension LibraryRepository {                     return (.invalidated(reason: Self.setGoneReason), [], nil)                 }                 return try self.resolveWorkSet(set, chosen: chosen, context: context)+            case .character:+                guard case .found(let set) = Self.matching(contract.setKey, in: scan.characterSets)+                else {+                    return (.invalidated(reason: Self.setGoneReason), [], nil)+                }+                return try self.resolveCharacterSet(set, chosen: chosen, context: context)             case .titleRule, .urlRule:                 // Rule groups carry nothing reader-authored and converge without                 // the reader (Q39); they can never reach a sheet.@@ -119,9 +125,24 @@ extension LibraryRepository {          let (result, losers, resolvedKey) = outcome         if case .committed(let survivorID) = result {-            let type: CollapsedRecordType =-                contract.setKey.recordType == .entry ? .entry : .work-            for loser in losers { recordCollapse(loser: loser, survivor: survivorID, type: type) }+            // Exhaustive, not a ternary: the ternary read every non-Entry type+            // as `.work`, so a character resolution would have recorded its+            // collapse in the Work redirect map. Dead today — `resolveCharacterSet`+            // returns no losers (Q76) — and wrong the day that changes.+            let type: CollapsedRecordType? =+                switch contract.setKey.recordType {+                case .entry: .entry+                case .work: .work+                case .character: .character+                // Rule sets never reach here: the switch above returns+                // `.invalidated` for them, so there is no collapse to record.+                case .titleRule, .urlRule: nil+                }+            if let type {+                for loser in losers {+                    recordCollapse(loser: loser, survivor: survivorID, type: type)+                }+            }             // Both keys: the one the sheet was opened against and the one the             // set had at commit, which differ whenever a copy arrived in             // between. The set is gone either way, and a ledger entry that@@ -192,6 +213,20 @@ extension LibraryRepository {                         differingFields: Self.differingWorkFields(set.variants),                         preselected: leading.id))             }+        case .character:+            switch matching(setKey, in: scan.characterSets) {+            case .gone: return .gone+            case .split: return .split+            case .found(let set):+                guard set.classification == .divergent, let leading = set.variants.first+                else { return .gone }+                return .found(+                    .character(+                        setKey: set.key,+                        variants: set.variants.map(Self.choice),+                        differingFields: Self.differingCharacterFields(set.variants),+                        preselected: leading.id))+            }         case .titleRule, .urlRule:             return .gone         }@@ -277,6 +312,33 @@ extension LibraryRepository {             firstCapturedAt: variant.firstCapturedAt)     } +    private static func choice(+        _ variant: AuthoredVariant<CharacterAuthoredContent>+    ) -> CharacterVariantChoice {+        CharacterVariantChoice(+            id: variant.id,+            name: variant.content.name,+            note: variant.content.note,+            aliases: variant.content.aliases,+            factCount: variant.content.facts.count,+            firstCapturedAt: variant.firstCapturedAt)+    }++    private static func differingCharacterFields(+        _ variants: [AuthoredVariant<CharacterAuthoredContent>]+    ) -> [DuplicateResolutionField] {+        // The name is always shown — it is how the reader tells the copies apart+        // even when it is the one field they agree on.+        var fields: [DuplicateResolutionField] = [.name]+        let contents = variants.map(\.content)+        if Set(contents.map(\.note)).count > 1 { fields.append(.note) }+        if Set(contents.map { $0.aliases.joined(separator: "\u{1F}") }).count > 1 {+            fields.append(.aliases)+        }+        if Set(contents.map { $0.factsData ?? Data() }).count > 1 { fields.append(.facts) }+        return fields+    }+     /// Req 4.2: "every authored field in which the set differs". A field all the     /// variants agree on is not a decision and does not belong on the sheet.     private static func differingEntryFields(@@ -398,6 +460,15 @@ extension LibraryRepository {         }          let losers = set.members.dropFirst().map(\.id)+        // Req 3.6: citations and suppression source references follow the+        // surviving row. Before the deletion, so the works are still reachable+        // through the rows about to go — and group-wide, so a rewrite cannot+        // false-tear a character (Q85).+        CharacterCitationRepointing.repoint(+            survivors: Dictionary(uniqueKeysWithValues: losers.map { ($0, survivorID) }),+            in: allRows.compactMap(\.work),+            timestamp: timestamp)+         for id in losers {             for row in rowsByID[id] ?? [] {                 // The pointer goes before the row does. `context.delete` leaves@@ -552,6 +623,50 @@ extension LibraryRepository {         return (.committed(survivorID: survivorID), losers, set.key)     } +    // MARK: - Commit: character groups++    /// Resolves a torn character group onto one variant (Req 6.5, Q87).+    ///+    /// **Chosen-only, never a union** — the `.work` arm's write shape without+    /// its note append. A union here would be the silent merge Req 6.4 forbids,+    /// and there is nothing to merge *into*: the group is one record whose rows+    /// disagree, so the reader is choosing which of their own edits survives.+    ///+    /// Nothing is deleted. A character set has one member (Q76), so the rows are+    /// the same record and all of them take the chosen content.+    private func resolveCharacterSet(+        _ set: CharacterDuplicateSet, chosen: VariantID, context: ModelContext+    ) throws -> (DuplicateResolutionOutcome, [UUID], DuplicateSetKey?) {+        guard let id = set.key.memberIDs.first else {+            return (.invalidated(reason: Self.setGoneReason), [], nil)+        }+        let rows = try Self.characterRows(ids: [id], context: context)[id] ?? []+        guard !rows.isEmpty else {+            return (.invalidated(reason: "The surviving copy no longer exists."), [], nil)+        }+        guard let chosenVariant = set.variants.first(where: { $0.id == chosen }) else {+            return (.invalidated(reason: "The chosen copy is not one of this set's."), [], nil)+        }+        let content = chosenVariant.content+        let timestamp = MillisecondInstant.quantize(clock.now())+        for row in rows {+            row.name = content.name+            row.note = content.note+            row.aliases = content.aliases+            row.factsData = content.factsData+            row.modifiedAt = timestamp+        }++        let hostname = rows.compactMap { $0.work?.siteHostname }.first ?? ""+        if let refusal = try commitResolution(+            context: context, hostname: hostname, operation: "resolution") {+            return (refusal, [], nil)+        }+        resolutionLogger.debug(+            "Resolved torn character group \(id.uuidString) onto one variant")+        return (.committed(survivorID: id), [], set.key)+    }+     // MARK: - Commit boundary      /// Validates the prospective graph and saves once, or rolls the whole
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift Modified +27 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swiftindex 4caddbf..d31fcc1 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift@@ -214,6 +214,31 @@ extension LibraryRepository {                 workDisplayTitle = nil             } +            // Req 5.4, in the same context (Q78's rule applied to a read+            // surface): a work's characters are few, so this is a scan of one+            // work's rows rather than a query the store cannot express — the+            // facts are a blob, and no predicate can reach inside one.+            var citingCharacters: [EntryCitingCharacter] = []+            if let workID = entrySnap.workID {+                let rows = Self.characterRows(+                    of: try context.fetch(+                        FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })))+                for group in Self.characterGroups(rows).values {+                    let count = group.presentedContent.facts.count {+                        $0.source == .entry(id)+                    }+                    guard count > 0 else { continue }+                    citingCharacters.append(+                        EntryCitingCharacter(+                            id: group.id, name: group.presentedContent.name, factCount: count))+                }+                citingCharacters.sort {+                    $0.name == $1.name+                        ? $0.id.uuidString < $1.id.uuidString+                        : $0.name.localizedStandardCompare($1.name) == .orderedAscending+                }+            }+             return EntryTeachingDetail(                 entry: entrySnap,                 siteMode: siteMode,@@ -230,7 +255,8 @@ extension LibraryRepository {                 } ?? entrySnap.captureTitle,                 workDisplayTitle: workDisplayTitle,                 hasCurrentURLRule: site?.urlRuleValues.contains(where: \.isCurrent) ?? false,-                groupState: group.state+                groupState: group.state,+                citingCharacters: citingCharacters             )         }     }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift Modified +5 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swiftindex f079bdc..d9dd48f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift@@ -27,6 +27,11 @@ internal enum ResolvedWriteTarget<Group> { internal enum CollapsedRecordType: Sendable, Hashable {     case entry     case work+    /// Present for completeness of the mapping, never populated: a character set+    /// has one member and no losers (Q76), so no character ever collapses into+    /// another. An explicit case is what makes that a statement rather than a+    /// silent gap in a ternary.+    case character }  extension LibraryRepository {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift Modified +10 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swiftindex 29fbad4..4af87e4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift@@ -167,6 +167,16 @@ extension LibraryRepository {                     }                 }             }+            // Req 3.4: a work's characters, suppressions and coverage are+            // deleted with it. The relationship is `.nullify`, so leaving them+            // would produce orphans indistinguishable from the tolerated+            // in-flight ones (Req 6.7) — inert for ever, and in every backup.+            // Held proposals are dropped by the coordinator's `reconcile()`.+            for row in group.rows {+                for character in row.characterValues { context.delete(character) }+                for suppression in row.characterSuppressionValues { context.delete(suppression) }+            }+             // The work group goes whole (Req 7.5): a proper subset left behind             // is a work the reader deleted that is still in their library.             for row in group.rows { context.delete(row) }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift Modified +43 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swiftindex db44c70..6da6775 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift@@ -51,15 +51,38 @@ public struct WorkDetailPresentation: Sendable, Equatable {     public let lastNotedURLString: String?     /// Newest-first by `lastSharedAt`, the app's own ordering (§7).     public let chapterRows: [WorkChapterRow]+    /// `character-extraction` Reqs 5.1/5.2: the work's characters, in name+    /// order, with their facts in Q88's display order and their citations+    /// already resolved.+    ///+    /// Read in the same locked context as everything else here, never by a+    /// second call: two surfaces answering the same question independently is+    /// how they come to disagree. Empty is the ordinary case, and what makes the+    /// section absent rather than empty (Req 5.1).+    public let characters: [WorkCharacterPresentation]+    /// Where each live entry falls in capture order, oldest first — Q88's other+    /// input, carried out with the characters rather than re-derived.+    ///+    /// The review sheet needs it too: a *proposed* fact has no+    /// `WorkCharacterPresentation` to have been ordered inside, and ordering the+    /// sheet's rows by entry UUID would show one merged row's facts in an order+    /// the work page then contradicts. Reversing `chapterRows` would be a second+    /// derivation of the same order, which is exactly what this field exists to+    /// prevent.+    public let captureOrder: [UUID: Int]      public init(         work: WorkSnapshot, pulse: RatingPulse, lastNotedURLString: String?,-        chapterRows: [WorkChapterRow]+        chapterRows: [WorkChapterRow],+        characters: [WorkCharacterPresentation] = [],+        captureOrder: [UUID: Int] = [:]     ) {         self.work = work         self.pulse = pulse         self.lastNotedURLString = lastNotedURLString         self.chapterRows = chapterRows+        self.characters = characters+        self.captureOrder = captureOrder     } } @@ -92,6 +115,20 @@ extension LibraryRepository {                     lastSharedAt: entry.lastSharedAt)             } +            // Q88's two inputs, from this same context: where each live entry+            // falls in capture order (oldest first, so a character's facts read+            // as history — Req 5.2), and what to call it.+            let captureOrdered = entries.sorted { $0.lastSharedAt < $1.lastSharedAt }+            var captureOrder: [UUID: Int] = [:]+            for (index, entry) in captureOrdered.enumerated() { captureOrder[entry.id] = index }+            let titles = Dictionary(+                rows.map { ($0.id, $0.displayTitle) }, uniquingKeysWith: { first, _ in first })+            // From the group's own rows, not a whole-table fetch filtered down+            // to this work: the answer is identical (an orphan is unreachable+            // through the inverse exactly as it failed the `work?.id` filter),+            // and this read runs on every open of every work page.+            let characterRows = Self.characterRows(of: group.rows)+             return WorkDetailPresentation(                 work: work,                 pulse: RatingPulse(@@ -99,7 +136,11 @@ extension LibraryRepository {                     up: entries.filter { $0.rating == .up }.count,                     down: entries.filter { $0.rating == .down }.count),                 lastNotedURLString: entries.first?.rawURLString,-                chapterRows: rows)+                chapterRows: rows,+                characters: Self.characterPresentations(+                    Self.characterGroups(characterRows),+                    captureOrder: captureOrder, titles: titles),+                captureOrder: captureOrder)         }     } }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift Modified +68 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 3868020..d915328 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -149,6 +149,59 @@ extension LibraryRepository {         }     } +    /// Req 3.4's character half of a merge, in the `repointEntries` shape+    /// (`DuplicateReconciler.swift:631-641`).+    ///+    /// Three writes, each with its own reason:+    ///+    /// * **Characters move by pointer**, keeping every fact, citation and+    ///   timestamp — the move is not an edit to them.+    /// * **Generic-notes citations repoint** to the target's generic notes. An+    ///   entry citation still names the entry it always named, because the+    ///   entries moved too; a generic-notes citation names *a work*, and after+    ///   the merge that work is the target. The rewrite is canonical and+    ///   group-wide, so it cannot false-tear (Q85).+    /// * **The target's coverage resets**, because its generic notes have+    ///   changed and its entry set has grown: a later sweep must revisit it.+    ///+    /// Suppressions move by pointer and union naturally — two rows of one key+    /// on one work is exactly the sync shape Q82's read-through already+    /// resolves.+    private static func moveCharacters(+        from source: WorkGroup, to target: WorkGroup, timestamp: Date, context: ModelContext+    ) {+        guard let survivor = target.rows.first else { return }+        var moved: [CharacterRecord] = []+        for row in source.rows {+            for character in row.characterValues {+                character.work = survivor+                moved.append(character)+            }+            for suppression in row.characterSuppressionValues {+                suppression.work = survivor+            }+        }+        for (_, group) in characterGroups(moved) {+            let facts = group.presentedContent.facts+            guard facts.contains(where: { $0.source == .genericNotes }) else { continue }+            // The citation form does not change — it is still `.genericNotes` —+            // but it now resolves against the target's notes, and the rewrite is+            // what re-encodes every row of the group canonically so the pointer+            // move cannot leave the group's bytes disagreeing.+            let bytes = CharacterFactCodec.encode(facts)+            for row in group.rows {+                row.factsData = bytes+                row.modifiedAt = timestamp+            }+        }+        for row in target.rows {+            row.genericNotesExtractionFingerprint = nil+        }+        for entry in target.rows.flatMap({ $0.entryValues }) {+            entry.characterExtractionFingerprint = nil+        }+    }+     public func projectMerge(         sourceWorkID: UUID,         targetWorkID: UUID@@ -297,6 +350,14 @@ extension LibraryRepository {                 }             } +            // Req 3.4: the source's characters move to the target with their+            // entry citations intact, its generic-notes citations repointed to+            // the target's generic notes, its suppressions unioned, and the+            // target's coverage reset so a later sweep revisits it. Before the+            // deletion, while the source rows are still reachable.+            Self.moveCharacters(+                from: sourceGroup, to: targetGroup, timestamp: timestamp, context: context)+             // Delete the source group whole (Req 2.2): a group is deleted             // entirely or not at all, and a proper subset left behind is the             // partial merge the old refusal was avoiding.@@ -415,11 +476,17 @@ extension LibraryRepository {             )         } +        // Req 3.4: the count the preview names. Logical records, not rows — a+        // split character group is one character the merge moves.+        let sourceRows = try context.fetch(+            FetchDescriptor<Work>(predicate: #Predicate { $0.id == sourceWorkID }))+        let sourceCharacters = characterRows(of: sourceRows)         return try WorkMergeBasis(             source: sourceBasis,             target: targetBasis,             currentRule: currentRule,-            ruleUnreadable: ruleUnreadable+            ruleUnreadable: ruleUnreadable,+            movedCharacterCount: Set(sourceCharacters.map(\.id)).count         )     } 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +3 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 00461bb..cbf2ae0 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -525,7 +525,9 @@ public actor LibraryRepository {                 switch plan.key.recordType {                 case .entry: .entry                 case .work: .work-                case .titleRule, .urlRule: nil+                // Neither ever produces a deletion plan — rules converge and are+                // never deleted (Q39), and a character set has no losers (Q76).+                case .titleRule, .urlRule, .character: nil                 }             guard let type else { continue }             for loser in plan.loserIDs {
Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift Modified +1 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swiftindex b497e4b..f5d009c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryWrites.swift@@ -1,4 +1,3 @@-import CryptoKit import Foundation  // The value layer of the fan-out write paths (Reqs 2.7–2.10).@@ -32,8 +31,7 @@ public struct VariantID: Sendable, Equatable, Hashable, Comparable, CustomString      init(components: [OrderComponent]) {         let canonical = components.map(\.canonicalToken).joined(separator: "\u{1F}")-        let digest = SHA256.hash(data: Data(canonical.utf8))-        self.rawValue = digest.map { String(format: "%02x", $0) }.joined()+        self.rawValue = Hexadecimal.sha256(canonical)     }      public static func < (lhs: Self, rhs: Self) -> Bool { lhs.rawValue < rhs.rawValue }
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +191 / -15
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex 70f7fd8..aca8821 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -1,23 +1,31 @@ import Foundation import SwiftData -// The live model classes are V6's, nested inside `AsterismSchemaV6` (Decision 6,+// The live model classes are V7's, nested inside `AsterismSchemaV7` (Decision 6, // Q20). Top-level typealiases keep every call site (`Entry`, `Site`, …) unchanged. //-// The nesting is what makes the frozen `AsterismSchemaV5` snapshot possible: it-// carries nested classes with the same SwiftData entity names, which is legal-// only while there is exactly one *top-level* `@Model` per entity name — and-// there are none, because every top-level name here is a typealias. Two-// top-level `@Model`s sharing an entity name crash `ModelContext`+// The nesting is what makes the frozen `AsterismSchemaV5` and `AsterismSchemaV6`+// snapshots possible: they carry nested classes with the same SwiftData entity+// names, which is legal only while there is exactly one *top-level* `@Model` per+// entity name — and there are none, because every top-level name here is a+// typealias. Two top-level `@Model`s sharing an entity name crash `ModelContext` // (`docs/agent-notes/schema-migration.md`).-public typealias Entry = AsterismSchemaV6.Entry-public typealias Work = AsterismSchemaV6.Work-public typealias Site = AsterismSchemaV6.Site-public typealias TitlePattern = AsterismSchemaV6.TitlePattern-public typealias URLRulePattern = AsterismSchemaV6.URLRulePattern-public typealias WorkTypeEntity = AsterismSchemaV6.WorkTypeEntity--extension AsterismSchemaV6 {+public typealias Entry = AsterismSchemaV7.Entry+public typealias Work = AsterismSchemaV7.Work+public typealias Site = AsterismSchemaV7.Site+public typealias TitlePattern = AsterismSchemaV7.TitlePattern+public typealias URLRulePattern = AsterismSchemaV7.URLRulePattern+public typealias WorkTypeEntity = AsterismSchemaV7.WorkTypeEntity+// `Character` is deliberately **not** aliased at the top level: the stdlib owns+// that name, and shadowing it module-wide would silently retype every+// `[Character]` in `MarkdownExport` and `HTMLEntityDecoder` — and every one a+// future file writes. The SwiftData entity is still named "Character" (that is+// the nested class's name, and what the CloudKit record type and the archive+// key on); only the Swift spelling call sites use is qualified.+public typealias CharacterRecord = AsterismSchemaV7.Character+public typealias CharacterSuppression = AsterismSchemaV7.CharacterSuppression++extension AsterismSchemaV7 {  @Model public final class Entry {@@ -70,6 +78,12 @@ public final class Entry {     public var workURLRuleVersion: Int?     public var workURLAssignmentKindRaw: String?     public var intentionallyUnattached: Bool = false+    /// V7: the fingerprint of the note text a character-extraction pass last+    /// covered (Q59). A **derived** field, excluded from authored content the+    /// way a parsed `chapterTitle` is: editing the note is what uncovers the+    /// revision, so this column tracks the model's work, never the reader's.+    /// Nil means the note has never been covered.+    public var characterExtractionFingerprint: String?      public init(         id: UUID = UUID(),@@ -170,8 +184,23 @@ public final class Work {     public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue     public var createdAt: Date = Date(timeIntervalSince1970: 0)     public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+    /// V7: the fingerprint of the generic-notes text a character-extraction pass+    /// last covered (Q59). Derived, like `Entry.characterExtractionFingerprint`,+    /// and excluded from authored content for the same reason.+    public var genericNotesExtractionFingerprint: String?     @Relationship(deleteRule: .nullify, inverse: \Entry.work)     public var entries: [Entry]?+    /// V7: the characters extracted or hand-created for this Work (Q58). The+    /// inverse of `Character.work`, `.nullify` exactly as `entries` is — the+    /// cascade is repository-enforced, so a nullified orphan is a tolerated+    /// in-flight state (Req 6.7) rather than a delete SwiftData performs behind+    /// the repository's back.+    @Relationship(deleteRule: .nullify, inverse: \Character.work)+    public var characters: [Character]?+    /// V7: the suppression rows scoped to this Work (Q60). Same shape and same+    /// reasoning as `characters`.+    @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)+    public var characterSuppressions: [CharacterSuppression]?      public init(id: UUID = UUID(), displayTitle: String, siteHostname: String, timestamp: Date) {         self.id = id@@ -197,6 +226,8 @@ public final class Work {     }      public var entryValues: [Entry] { entries ?? [] }+    public var characterValues: [Character] { characters ?? [] }+    public var characterSuppressionValues: [CharacterSuppression] { characterSuppressions ?? [] } }  @Model@@ -597,7 +628,152 @@ public final class WorkTypeEntity {     } } -} // extension AsterismSchemaV6+/// V7: one character of one work — accepted from an extraction proposal or+/// created by hand (Decision 1). Reader-authored throughout: the name, the+/// aliases, the note and the facts' statements are all editable, so the record+/// participates in the duplicate/torn machinery like an Entry or a Work.+///+/// Facts are a Codable blob rather than a table (Q57), on the+/// `URLRulePattern.definitionData` precedent: a fact edit is a character-level+/// authored change, so a character tears at character granularity, and the+/// entry-detail lookup scans a work's characters, where N is small.+///+/// `nameKey` is **retained**: minted once at accept or creation commit (Q19/Q46)+/// and never re-derived from a rename, so a renamed character keeps attracting+/// the proposals that named it before (Req 2.3's second matching tier).+///+/// Every property is defaulted or optional and nothing is unique: this is a+/// CloudKit-mirrored table like the rest.+@Model+public final class Character {+    public var id: UUID = UUID()+    public var name: String = ""+    /// The key the character was accepted or created under, retained through+    /// renames (Q19/Q46). Normalised by `CharacterNameKey.normalize`.+    public var nameKey: String = ""+    /// Extra match keys, reader-editable (Q56) and grown by the combine+    /// (Decision 4). Stored as the reader's spellings; matching normalises.+    public var aliases: [String] = []+    public var note: String = ""+    /// `[CharacterFact]` in the canonical encoding (Q75). Optional rather than+    /// defaulted-empty because CloudKit materialises a missing column as nil,+    /// and "no facts yet" and "column not synced" are the same thing to a+    /// reader.+    public var factsData: Data?+    public var createdAt: Date = Date(timeIntervalSince1970: 0)+    public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+    /// The Work this character belongs to. Nil is a **tolerated** in-flight+    /// state (Req 6.7, Q70): a character that synced ahead of its work is inert+    /// — reachable only through the work — and appears when the work lands.+    public var work: Work?++    /// Every parameter defaulted, so memberwise construction and the stored+    /// defaults are the same value CloudKit would materialise.+    public init(+        id: UUID = UUID(),+        name: String = "",+        nameKey: String = "",+        aliases: [String] = [],+        note: String = "",+        facts: [CharacterFact] = [],+        timestamp: Date = Date(timeIntervalSince1970: 0),+        work: Work? = nil+    ) {+        self.id = id+        self.name = name+        self.nameKey = nameKey+        self.aliases = aliases+        self.note = note+        factsData = CharacterFactCodec.encode(facts)+        createdAt = timestamp+        modifiedAt = timestamp+        self.work = work+    }++    /// The stored facts, in canonical order. Undecodable bytes read as no facts+    /// rather than throwing: a fact blob is reader data arriving over CloudKit,+    /// and a row that cannot be read must still display its name (Req 6.7).+    public var facts: [CharacterFact] {+        get { CharacterFactCodec.decode(factsData) }+        set { factsData = CharacterFactCodec.encode(newValue) }+    }++}++/// V7: one remembered decision not to re-propose something (Q60/Q72).+///+/// A **system record**, never reader-authored (Q40): it never tears, never+/// blocks an export, and converges to the reader's most recent action rather+/// than by set union — which is what makes a clear (Req 2.5) representable at+/// all. Writes update the local row in place, keyed by+/// (work, kind, nameKey, source, evidence); rows duplicated by sync are read+/// through by latest `actionAt`, tie-broken cleared-wins then lowest row UUID+/// (Q82).+@Model+public final class CharacterSuppression {+    public var id: UUID = UUID()+    /// Nil is tolerated for the same reason `Character.work` tolerates it.+    public var work: Work?+    public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue+    public var nameKey: String = ""+    /// Present on fact rows only; nil on candidate rows. Explicit rather than+    /// inferred from a nil `sourceEntryID`, so a malformed row is+    /// distinguishable from a generic-notes citation (Q72).+    public var sourceKindRaw: String?+    /// Set only where `sourceKind == .entry`.+    public var sourceEntryID: UUID?+    /// The fact's evidence span, on fact rows only. The third component of the+    /// identity triple (Q29).+    public var evidence: String?+    public var statusRaw: String = CharacterSuppressionStatus.active.rawValue+    /// When the reader acted. The comparable Q52 convergence needs (Q60).+    public var actionAt: Date = Date(timeIntervalSince1970: 0)++    public init(+        id: UUID = UUID(),+        work: Work? = nil,+        kind: CharacterSuppressionKind = .candidate,+        nameKey: String = "",+        source: SourceRef? = nil,+        evidence: String? = nil,+        status: CharacterSuppressionStatus = .active,+        actionAt: Date = Date(timeIntervalSince1970: 0)+    ) {+        self.id = id+        self.work = work+        kindRaw = kind.rawValue+        self.nameKey = nameKey+        sourceKindRaw = source?.kindRaw+        sourceEntryID = source?.entryID+        self.evidence = evidence+        statusRaw = status.rawValue+        self.actionAt = actionAt+    }++    /// An unknown raw value reads as `.candidate`, matching every other enum+    /// column in this schema: unrecognised data is tolerated, not corruption.+    public var kind: CharacterSuppressionKind {+        get { CharacterSuppressionKind(rawValue: kindRaw) ?? .candidate }+        set { kindRaw = newValue.rawValue }+    }++    public var status: CharacterSuppressionStatus {+        get { CharacterSuppressionStatus(rawValue: statusRaw) ?? .active }+        set { statusRaw = newValue.rawValue }+    }++    /// The cited source on a fact row, or nil on a candidate row — and nil for a+    /// malformed row, which is why `sourceKindRaw` is stored explicitly.+    public var source: SourceRef? {+        get { SourceRef(kindRaw: sourceKindRaw, entryID: sourceEntryID) }+        set {+            sourceKindRaw = newValue?.kindRaw+            sourceEntryID = newValue?.entryID+        }+    }+}++} // extension AsterismSchemaV7  extension Work {     /// Req 3.21's one rule, in one place: a reuse or claim refreshes the Work's
Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift Modified +15 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex 8c30d24..8bf66d0 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -552,12 +552,18 @@ public struct WorkMergeBasis: Equatable, Sendable {     /// rule at all: that answer clears the merged Work's identity, and a rule     /// this build cannot read is no reason to (Req 4.4).     public let ruleUnreadable: Bool+    /// How many of the source's characters the merge moves (Req 3.4). Part of+    /// the basis rather than a display extra, so a character arriving between+    /// the preview and the confirmation refreshes the sheet the way an arriving+    /// entry does — the reader confirmed a count.+    public let movedCharacterCount: Int      public init(         source: WorkMergeWorkBasis,         target: WorkMergeWorkBasis,         currentRule: URLRuleBasisEntry?,-        ruleUnreadable: Bool = false+        ruleUnreadable: Bool = false,+        movedCharacterCount: Int = 0     ) throws {         guard source.snapshot.id != target.snapshot.id else {             throw WorkMergePlanningError.sameWork@@ -590,6 +596,7 @@ public struct WorkMergeBasis: Equatable, Sendable {         self.target = target         self.currentRule = currentRule         self.ruleUnreadable = ruleUnreadable+        self.movedCharacterCount = movedCharacterCount     } } @@ -636,6 +643,10 @@ public struct WorkMergeOutcome: Equatable, Sendable {     public let retainedFields: [WorkMergeField]     public let discardedFields: [WorkMergeField]     public let sourceDeleted: Bool+    /// Req 3.4: characters the merge moves to the target, for an honest preview.+    /// Defaulted so a caller building an outcome by hand — the app's merge-model+    /// tests do — is not forced to state a count it has no rows for.+    public let movedCharacterCount: Int      public init(         sourceID: UUID,@@ -657,8 +668,10 @@ public struct WorkMergeOutcome: Equatable, Sendable {         issues: [WorkMergeIssue],         retainedFields: [WorkMergeField],         discardedFields: [WorkMergeField],-        sourceDeleted: Bool+        sourceDeleted: Bool,+        movedCharacterCount: Int = 0     ) {+        self.movedCharacterCount = movedCharacterCount         self.sourceID = sourceID         self.targetID = targetID         self.displayTitle = displayTitle
Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift Modified +28 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex 307a690..338ac53 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -239,6 +239,15 @@ public struct EntryTeachingDetail: Equatable, Sendable {     /// has to disclose them before a delete and the commit re-verifies exactly     /// what was disclosed (Req 2.9).     public let groupState: RecordGroupState<EntryAuthoredContent>+    /// Req 5.4: the characters holding a fact that cites this entry, by+    /// presented name, in name order. Empty where none do — which is the+    /// ordinary case, and what makes the section absent rather than empty.+    ///+    /// Populated in the same locked context as everything else here+    /// (`LibraryRepository+EntryDetail.swift`), never by a second read: two+    /// surfaces resolving the same question independently is how they come to+    /// disagree.+    public let citingCharacters: [EntryCitingCharacter]      public init(         entry: EntrySnapshot, siteMode: SiteMode,@@ -250,9 +259,11 @@ public struct EntryTeachingDetail: Equatable, Sendable {         displayTitle: String? = nil,         workDisplayTitle: String? = nil,         hasCurrentURLRule: Bool = false,-        groupState: RecordGroupState<EntryAuthoredContent> = .single+        groupState: RecordGroupState<EntryAuthoredContent> = .single,+        citingCharacters: [EntryCitingCharacter] = []     ) {         self.groupState = groupState+        self.citingCharacters = citingCharacters         self.entry = entry         self.displayTitle = displayTitle ?? entry.captureTitle         self.workDisplayTitle = workDisplayTitle@@ -267,6 +278,22 @@ public struct EntryTeachingDetail: Equatable, Sendable {     } } +/// One character with a fact citing the entry being shown (Req 5.4).+public struct EntryCitingCharacter: Equatable, Sendable, Identifiable {+    public let id: UUID+    public let name: String+    /// How many of this character's facts cite the entry. A character can cite+    /// one entry more than once, and saying "3 facts" is more honest than+    /// listing the name three times.+    public let factCount: Int++    public init(id: UUID, name: String, factCount: Int) {+        self.id = id+        self.name = name+        self.factCount = factCount+    }+}+ /// Summary of a pattern rule for display, with full provenance diagnostics. public struct PatternRuleSummary: Equatable, Sendable {     public let id: UUID
Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift Added +161 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swiftnew file mode 100644index 0000000..45bdc50--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift@@ -0,0 +1,161 @@+import Foundation+import SwiftData++// What the work page shows under Characters, and what a bundle row in the review+// sheet shows beside its proposals (Reqs 5.1, 5.2, Q38).+//+// Derived in the repository rather than in the view for the reason every other+// §5 projection is: the fact order Q88 pins needs the entries' capture order and+// their display titles, both of which the view would have to re-derive — and a+// second derivation of a display order is a second answer to it.++/// One of a character's facts, ready to draw (Req 5.2).+public struct WorkCharacterFactRow: Identifiable, Sendable, Equatable {+    /// Stable within a character and derived from the fact itself, so a redraw+    /// after an edit does not re-identify every row. Not the identity triple:+    /// two copies edited apart share one triple (Q98) and would collide.+    public let id: String+    public let statement: String+    /// Immutable after acceptance (Q74) — displayed, never edited.+    public let quote: String+    public let source: SourceRef+    /// The entry to navigate to, where the citation still resolves. Nil for a+    /// generic-notes citation, which navigates to the work's own notes, and nil+    /// for a dangling one, which does not navigate at all (Req 3.5).+    public let citedEntryID: UUID?+    /// What to call the cited source: the entry's display title, or nil where+    /// the citation is the work's generic notes or has gone dangling.+    public let citationTitle: String?+    /// Req 3.5: the cited source no longer exists. A tolerated state, never an+    /// integrity error — the statement and the quote are still the reader's.+    public let isDangling: Bool+    /// The stored value, so an edit session can carry it into a draft without a+    /// second read.+    public let fact: CharacterFact++    public init(+        id: String, statement: String, quote: String, source: SourceRef,+        citedEntryID: UUID?, citationTitle: String?, isDangling: Bool, fact: CharacterFact+    ) {+        self.id = id+        self.statement = statement+        self.quote = quote+        self.source = source+        self.citedEntryID = citedEntryID+        self.citationTitle = citationTitle+        self.isDangling = isDangling+        self.fact = fact+    }+}++/// One character of a work, as every surface that displays one sees it.+public struct WorkCharacterPresentation: Identifiable, Sendable, Equatable {+    public let id: UUID+    public let name: String+    public let note: String+    public let aliases: [String]+    /// The retained key (Q19/Q46) — what a proposal matches on, and what the+    /// review sheet compares a bundle's target against.+    public let nameKey: String+    /// Ordered per Q88: generic-notes facts, then live citations in capture+    /// order, then dangling citations last, each tier broken by (quote,+    /// statement) so copies edited apart still order totally.+    public let facts: [WorkCharacterFactRow]+    /// Req 6.5: the rows of this character's identity group disagree about+    /// something the reader wrote. Read-only until the reader resolves it.+    public let isTorn: Bool+    /// How many rows the group holds, for the notice that says why editing is+    /// off.+    public let rowCount: Int+    /// What the editor opened on, for the whole-step basis check (Q73).+    public let editBasis: CharacterEditBasis++    public init(+        id: UUID, name: String, note: String, aliases: [String], nameKey: String,+        facts: [WorkCharacterFactRow], isTorn: Bool, rowCount: Int,+        editBasis: CharacterEditBasis+    ) {+        self.id = id+        self.name = name+        self.note = note+        self.aliases = aliases+        self.nameKey = nameKey+        self.facts = facts+        self.isTorn = isTorn+        self.rowCount = rowCount+        self.editBasis = editBasis+    }+}++// MARK: - Derivation++extension LibraryRepository {++    /// The work's characters as the page draws them, in name order.+    ///+    /// `captureOrder` maps a live entry's UUID to its position oldest-first, and+    /// `titles` to what that entry is called. Both come from the same locked+    /// read as the characters themselves.+    internal static func characterPresentations(+        _ groups: [UUID: CharacterGroup],+        captureOrder: [UUID: Int],+        titles: [UUID: String]+    ) -> [WorkCharacterPresentation] {+        groups.values+            .map { group in+                let content = group.presentedContent+                return WorkCharacterPresentation(+                    id: group.id,+                    name: content.name,+                    note: content.note,+                    aliases: content.aliases,+                    nameKey: group.carrier.nameKey,+                    facts: factRows(+                        content.facts, captureOrder: captureOrder, titles: titles),+                    isTorn: group.isTorn,+                    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.+    internal static func factRows(+        _ facts: [CharacterFact],+        captureOrder: [UUID: Int],+        titles: [UUID: String]+    ) -> [WorkCharacterFactRow] {+        facts+            .map { fact -> WorkCharacterFactRow in+                switch fact.source {+                case .genericNotes:+                    return WorkCharacterFactRow(+                        id: rowID(fact), statement: fact.statement, quote: fact.quote,+                        source: fact.source, citedEntryID: nil, citationTitle: nil,+                        isDangling: false, fact: fact)+                case .entry(let entryID):+                    let live = captureOrder[entryID] != nil+                    return WorkCharacterFactRow(+                        id: rowID(fact), statement: fact.statement, quote: fact.quote,+                        source: fact.source,+                        citedEntryID: live ? entryID : nil,+                        citationTitle: live ? titles[entryID] : nil,+                        isDangling: !live, fact: fact)+                }+            }+            .sorted { left, right in+                let leftTier = left.source.displayTier(captureOrder: captureOrder)+                let rightTier = right.source.displayTier(captureOrder: captureOrder)+                if leftTier != rightTier { return leftTier < rightTier }+                if left.quote != right.quote { return left.quote < right.quote }+                return left.statement < right.statement+            }+    }++    private static func rowID(_ fact: CharacterFact) -> String { fact.displayRowID }+}
Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift Modified +2 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swiftindex 8e04dfd..212098e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift@@ -132,7 +132,8 @@ public enum WorkMergePlanner {             issues: issues,             retainedFields: retained,             discardedFields: discarded,-            sourceDeleted: true+            sourceDeleted: true,+            movedCharacterCount: basis.movedCharacterCount         )     } 
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift Added +143 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swiftnew file mode 100644index 0000000..e404c35--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift@@ -0,0 +1,143 @@+import AsterismCore+import Foundation++/// Turns a pass's grounded output into the rows the reader decides on.+///+/// Three jobs, in this order, and the order is the design:+///+/// 1. **Group by name key** — one candidate or bundle per key per pass (Q83).+///    Per-source rows would show the same character once per note.+/// 2. **Resolve** the key onto an existing character, in the deterministic+///    total order Req 2.3 sets (current name, retained key, alias, lowest UUID+///    within a tier). Proposed aliases deliberately take no part (Q93).+/// 3. **Canonicalise and filter** — a routed proposal's facts are re-keyed to+///    the target's retained key *before* dedup and suppression are consulted+///    (Q79), which is what makes an alias spelling of an accepted quote dedup+///    instead of coming back.+///+/// Pure and deterministic: the same input assembles the same list, which is+/// what lets the reader-visible behaviour not depend on the model repeating+/// itself (Req 1.7).+public enum CharacterExtractionAssembler {+    public static func assemble(+        _ candidates: [GroundedCandidate],+        revisions: [SourceRef: String],+        context: CharacterExtractionContext,+        pass: ExtractionPassKind+    ) -> [ExtractionProposal] {+        var order: [String] = []+        var grouped: [String: [GroundedCandidate]] = [:]+        for candidate in candidates {+            if grouped[candidate.nameKey] == nil { order.append(candidate.nameKey) }+            grouped[candidate.nameKey, default: []].append(candidate)+        }++        var proposals: [ExtractionProposal] = []+        for key in order {+            guard let group = grouped[key],+                  let proposal = proposal(for: group, key: key, revisions: revisions,+                                          context: context, pass: pass)+            else { continue }+            proposals.append(proposal)+        }+        // Ordered by key, then capped: the list a reader sees is the same list+        // on any device, and bounded whatever the model returned (Req 1.10).+        return Array(proposals.sorted { $0.nameKey < $1.nameKey }+            .prefix(CharacterExtractionBounds.maximumCandidates))+    }++    private static func proposal(+        for group: [GroundedCandidate], key: String,+        revisions: [SourceRef: String],+        context: CharacterExtractionContext, pass: ExtractionPassKind+    ) -> ExtractionProposal? {+        let match = resolve(key, among: context.characters)+        let target: ExtractionProposal.Target = match.map { .existing($0.id) } ?? .newCharacter++        // A name-key suppression blocks a *new candidate* only. A character the+        // work already has stays enrichable regardless of what was skipped+        // under its name (Q47), and a manual pass exists to get past both+        // (Req 1.11, Q49).+        if pass == .automatic, match == nil, context.suppressedNameKeys.contains(key) {+            return nil+        }++        // Q79: everything below is keyed to the character the proposal will+        // actually be written to.+        let resolvedKey = match?.retainedKey ?? key+        var facts: [GroundedFact] = []+        var seen: Set<CharacterFactIdentity> = []+        for candidate in group {+            for fact in candidate.facts {+                let keyed = fact.keyed(to: resolvedKey)+                guard seen.insert(keyed.identity).inserted else { continue }+                guard !context.acceptedFacts.contains(keyed.identity) else { continue }+                if pass == .automatic, context.suppressedFacts.contains(keyed.identity) { continue }+                facts.append(keyed)+            }+        }+        facts.sort(by: isOrderedBefore)++        let knownKeys = match?.matchKeys ?? []+        var aliases: [String] = []+        var aliasKeys: Set<String> = [key]+        for candidate in group {+            for alias in candidate.proposedAliases {+                let aliasKey = CharacterNameKey.normalize(alias)+                guard !knownKeys.contains(aliasKey), aliasKeys.insert(aliasKey).inserted else {+                    continue+                }+                aliases.append(alias)+            }+        }++        // Req 1.7: a row with nothing to decide is not shown. A *new* name is+        // itself new content, so a name-only candidate stands (Q25); a bundle+        // needs a fact or an alias the character does not already have.+        if match != nil, facts.isEmpty, aliases.isEmpty { return nil }+        if match == nil, facts.isEmpty, !group.contains(where: { $0.facts.isEmpty }) {+            // Everything this candidate offered was already accepted, which+            // means it is not a new character at all — its target was renamed+            // or deleted out from under the key. Nothing left to decide.+            return nil+        }++        var cited: [SourceRef: String] = [:]+        for candidate in group {+            cited[candidate.source] = revisions[candidate.source]+        }++        return ExtractionProposal(+            name: group[0].name, nameKey: key, proposedAliases: aliases, target: target,+            facts: facts, citedRevisions: cited)+    }++    // MARK: - Matching (Req 2.3, Q67, Q51)++    /// The pipeline's spelling of the store's one matching rule. The tiers+    /// themselves live in `CharacterMatching` (AsterismCore), because the+    /// decision commit re-runs them against the store and the two answers have+    /// to be the same answer.+    static func resolve(_ key: String, among characters: [ExistingCharacter]) -> ExistingCharacter? {+        guard let target = CharacterMatching.match(+            nameKey: key, among: characters.map(\.matchTarget))+        else { return nil }+        return characters.first { $0.id == target.id }+    }++    /// Q75's **canonical** order: generic notes first, then entries by UUID,+    /// then by quote and statement so copies edited apart still order totally.+    ///+    /// Not Q88's display order, which puts entry citations in the notes'+    /// *capture* order — a thing the pipeline cannot see and has no business+    /// guessing. The display surfaces sort for themselves+    /// (`SourceRef.displayTier(captureOrder:)`); this order is what merging,+    /// dedup and encoding agree on.+    static func isOrderedBefore(_ left: GroundedFact, _ right: GroundedFact) -> Bool {+        if left.source != right.source {+            return left.source.orderToken < right.source.orderToken+        }+        if left.quote != right.quote { return left.quote < right.quote }+        return left.statement < right.statement+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift Added +39 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swiftnew file mode 100644index 0000000..1aba4e8--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift@@ -0,0 +1,39 @@+import Foundation++/// Every tunable the extraction pipeline has, in one place (Req 1.2, 1.10).+///+/// The values are the prototype's, not the requirements' provisional ones+/// (Q54): the spike measured a median of 8.3 s and a maximum of 23.5 s per+/// request on an M-series Mac, which makes Q24's 10 s attempt timeout a+/// guillotine for half of all requests. A phone is slower still, so the device+/// spike may move these again — this is the only file it has to touch.+public enum CharacterExtractionBounds {+    /// Works one activation may actually process (Req 1.2).+    public static let worksPerActivation = 2++    /// Works one activation may look at to find those two. Bounds the library+    /// read, not the model time.+    public static let worksExamined = 100++    /// How long one source's attempt may run, measured from the model request.+    public static let attemptTimeout: Duration = .seconds(30)++    /// Cumulative model time one app run may spend on the sweep. Exhaustion+    /// stops automatic work only — a manual pass is the reader's (Req 1.11).+    public static let runTimeBudget: Duration = .seconds(120)++    /// Candidates kept from one model response, and proposals shown from one+    /// pass. The same bound applies at both output points: neither the model+    /// nor an aggregation of it may hand the reader an unbounded list.+    public static let maximumCandidates = 24++    /// Facts kept per candidate (Req 1.10).+    public static let maximumFactsPerCandidate = 12++    /// Longest proposed name, statement and evidence span. Over-long values are+    /// **dropped, never truncated**: a cut evidence span is no longer the+    /// verbatim quote Req 1.6 requires, and a cut name no longer matches.+    public static let maximumNameLength = 100+    public static let maximumStatementLength = 500+    public static let maximumEvidenceLength = 500+}
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift Added +119 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swiftnew file mode 100644index 0000000..3780f3e--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift@@ -0,0 +1,119 @@+import AsterismCore+import Foundation++// The seam between the store's values and the pipeline's.+//+// `SourceRef`, `CharacterFact` and `CharacterFactIdentity` are shared outright —+// a second spelling of any of them would let the pipeline and the store+// disagree about whether a fact is the same fact. What is *not* shared is the+// shape of a request and of a held row: the pipeline's DTOs carry a proposal+// mid-flight, the store's carry a committed decision, and they answer different+// questions.+//+// Every conversion between the two lives here, so the coordinator that owns+// both sides is assembling values rather than translating them.++// MARK: - The candidate read → the pass's input (Q78)++extension ExistingCharacter {+    /// A store match target as the assembler sees it.+    ///+    /// The target carries **keys**, not display strings, and that is all the+    /// pipeline compares: `matchKeys` re-normalises what it is given, and+    /// normalisation is idempotent (Q99), so a key put where a name goes keys to+    /// itself. The proposal's display name comes from the model's grounded+    /// spelling, never from here.+    public init(_ target: CharacterMatchTarget) {+        self.init(+            id: target.id, name: target.currentNameKey, retainedKey: target.retainedKey,+            aliases: target.aliasKeys)+    }+}++extension CharacterExtractionContext {+    /// The whole filter input, from the one locked read that produced it (Q78).+    public init(_ candidate: CharacterExtractionCandidate) {+        self.init(+            characters: candidate.characters.map(ExistingCharacter.init),+            acceptedFacts: candidate.acceptedFactIdentities,+            suppressedNameKeys: candidate.suppressions.candidateKeys,+            suppressedFacts: candidate.suppressions.factIdentities)+    }+}++extension CharacterExtractionCandidate {+    /// The uncovered sources as model requests: the work's display title and one+    /// source's text, and nothing else (Req 1.4, Q42).+    public var pendingExtractionSources: [ExtractionSource] {+        extractionSources(includingCovered: false)+    }++    /// The same, with the choice the two passes disagree about made explicit.+    ///+    /// A sweep processes what is uncovered; the reader's manual pass processes+    /// the work's sources **regardless of coverage** (Req 1.11), which is the+    /// whole point of offering it — a source covered by a pass that produced+    /// nothing, or whose proposals were all skipped, is otherwise unreachable.+    public func extractionSources(includingCovered: Bool) -> [ExtractionSource] {+        (includingCovered ? sources : uncoveredSources).map {+            ExtractionSource(+                workID: workID, workTitle: displayTitle, source: $0.ref, text: $0.text,+                fingerprint: $0.fingerprint)+        }+    }++    /// Every source's current revision, which is what a proposal's staleness is+    /// judged against (Req 2.7).+    public var revisionsBySource: [SourceRef: String] {+        Dictionary(uniqueKeysWithValues: sources.map { ($0.ref, $0.fingerprint) })+    }+}++// MARK: - A held proposal → a decision (Req 2.2)++extension GroundedFact {+    /// The stored shape of the same fact. Identity is unchanged: it is the same+    /// triple either side of the boundary (Q29).+    public var storedFact: CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    }+}++extension ExtractionProposal {+    /// The revisions this row rests on, as the sources a decision completes —+    /// verified against current text and then written as coverage in the same+    /// save (Req 2.7, Q65). Ordered so two devices send the same request.+    public var completedSources: [CharacterCompletedSource] {+        citedRevisions+            .map { CharacterCompletedSource(ref: $0.key, fingerprint: $0.value) }+            .sorted { $0.ref.orderToken < $1.ref.orderToken }+    }++    /// The decision the sheet commits for this row.+    ///+    /// `struckAliases` are the aliases the reader struck before deciding and+    /// `untickedFacts` the facts they unticked: both are subtracted from what+    /// the row *displayed*, because a skip suppresses exactly the displayed keys+    /// and an accept clears exactly them (Q92). The unticked facts travel+    /// separately and are suppressed under either action (Req 2.4).+    public func decisionRequest(+        workID: UUID,+        action: CharacterDecisionAction,+        struckAliases: Set<String> = [],+        untickedFacts: Set<CharacterFactIdentity> = []+    ) -> CharacterDecisionRequest {+        let shownAliases = proposedAliases.filter { !struckAliases.contains($0) }+        let shownFacts = facts.map(\.storedFact)+        let targetID: UUID? = if case .existing(let id) = target { id } else { nil }+        return CharacterDecisionRequest(+            workID: workID,+            action: action,+            displayedKeys: [nameKey] + shownAliases.map(CharacterNameKey.normalize),+            displayedTargetID: targetID,+            proposedName: name,+            proposedAliases: shownAliases,+            facts: shownFacts.filter { !untickedFacts.contains($0.identity) },+            untickedFacts: shownFacts.map(\.identity).filter(untickedFacts.contains),+            completedSources: completedSources)+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift Added +413 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swiftnew file mode 100644index 0000000..2581a12--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift@@ -0,0 +1,413 @@+import AsterismCore+import Foundation++/// One source of one work: the unit a model request processes, and therefore+/// the unit the run remembers having attempted.+///+/// The work is part of the key because `.genericNotes` is not unique on its+/// own — every work has one.+public struct ExtractionSourceKey: Sendable, Hashable {+    public var work: UUID+    public var source: SourceRef++    public init(work: UUID, source: SourceRef) {+        self.work = work+        self.source = source+    }+}++/// Why the ledger refused to start an attempt. Carried out of `start` rather+/// than re-derived by the caller: the state that decided it can have moved on+/// by the time the caller asks, and a log line naming a different reason than+/// the one applied is worse than no log line.+public enum ExtractionRefusalReason: Sendable, Equatable, CustomStringConvertible {+    /// Failed, was refused, or timed out this run (Req 1.8).+    case attempted+    /// Another source's attempt holds the floor (Req 1.3).+    case inFlight(ExtractionSourceKey)+    /// The run's cumulative model-time budget is spent (Req 1.2).+    case budgetExhausted+    case notActive+    case lowPower+    case thermallyConstrained++    public var description: String {+        switch self {+        case .attempted: "already attempted this run"+        case .inFlight(let key): "attempt in flight for \(key.work)"+        case .budgetExhausted: "run budget spent"+        case .notActive: "app not active"+        case .lowPower: "low power mode"+        case .thermallyConstrained: "thermally constrained"+        }+    }+}++/// What the caller must do about a requested attempt.+public enum ExtractionAttemptStart: Sendable, Equatable {+    /// Run it: the ledger has recorded it as in flight.+    case start+    case refuse(ExtractionRefusalReason)+    /// Cancel the running attempt, let it settle, then ask again. The ledger+    /// deliberately does not swap the in-flight record itself — the new attempt+    /// may not start until the cancelled one has terminated.+    case preempt(ExtractionSourceKey)++    public var refusalReason: ExtractionRefusalReason? {+        guard case .refuse(let reason) = self else { return nil }+        return reason+    }+}++/// How an attempt ended.+///+/// The distinction that matters is whether the source counts as attempted: a+/// failure, a refusal and a timeout do (Req 1.8), a cancellation the app itself+/// caused does not (Q69).+///+/// Every ending of an **automatic** attempt charges the run budget, the+/// pre-empted ones included. A manual pass charges nothing (Q101): the budget+/// exists to bound the sweep (Req 1.2), the reader's pass runs regardless of it+/// (Req 1.11), and charging it would let one manual pass silently end the+/// sweep for the rest of the app run.+public enum ExtractionSettlement: Sendable, Equatable {+    /// Settled with grounded, filtered output. An empty array is "produced+    /// none", which is a real result: the source covers at pass time (Q65).+    case proposals([ExtractionProposal])+    /// A model failure, a guardrail refusal (Q22), a decode failure, or a+    /// source too long for one request (Req 1.5, Q35). Skipped, remembered,+    /// left uncovered.+    case failed+    case timedOut+    /// The app's own pre-emption. Charged, not remembered, still uncovered, so+    /// it may be retried later in the run.+    case cancelled+}++/// A work's current state, as `reconcile` needs to see it (Req 2.8).+public struct WorkExtractionState: Sendable, Equatable {+    /// Every source the work has now, with its current fingerprint. A proposal+    /// citing a revision that is not in here has gone stale.+    public var revisions: [SourceRef: String]+    /// The work's characters. A bundle targeting one that is gone is discarded.+    public var characterIDs: Set<UUID>++    public init(revisions: [SourceRef: String], characterIDs: Set<UUID>) {+        self.revisions = revisions+        self.characterIDs = characterIDs+    }+}++/// Every piece of per-run state this feature keeps, and the rules for moving+/// between its states. Pure and value-typed: the coordinator owns the `Task`s,+/// the clock, the lane token and the library; the ledger owns the bookkeeping.+///+/// Nothing here is ever written to disk (Q61). Held proposals are device-local+/// and re-derivable — their revisions are not covered until a decision commits+/// — so losing them to a restart or a memory warning costs a sweep, not data+/// (Req 2.6).+public struct CharacterExtractionLedger: Sendable, Equatable {+    public struct InFlight: Sendable, Equatable {+        public let key: ExtractionSourceKey+        public let pass: ExtractionPassKind+        /// The revision the attempt was computed against.+        public let fingerprint: String+        /// Set when that revision changed under it. The attempt is still+        /// running — the coordinator owns the `Task` — but whatever it returns+        /// is about a source that no longer exists, so `settle` throws the+        /// result away instead of holding it.+        public private(set) var voided = false++        public init(key: ExtractionSourceKey, pass: ExtractionPassKind,+                    fingerprint: String, voided: Bool = false) {+            self.key = key+            self.pass = pass+            self.fingerprint = fingerprint+            self.voided = voided+        }++        fileprivate mutating func void() { voided = true }+    }++    public private(set) var held: [UUID: [ExtractionProposal]] = [:]+    public private(set) var attempted: Set<ExtractionSourceKey> = []+    public private(set) var inFlight: InFlight?+    public private(set) var budgetSpent: Duration = .zero++    /// The sweep generation state machine (Req 1.1), shared with the rule+    /// ledger — the two features run the same sweep shape and were running two+    /// copies of the same forty lines.+    private var sweepGate = SweepGate()++    /// The sweep that may still start attempts. `resignActive` and pre-emption+    /// clear it: this is the sweep's **stop signal**.+    public var permittedSweep: Int? { sweepGate.permitted }+    /// The sweep coroutine that has begun and not yet ended — the+    /// **single-instance guard**, deliberately not the same thing as the stop+    /// signal: a stopped sweep is still running until it notices.+    public var runningSweep: Int? { sweepGate.running }++    public init() {}++    // MARK: - Reads++    public func held(for work: UUID) -> [ExtractionProposal] { held[work] ?? [] }++    /// The works whose page should show the proposals indicator (Req 2.1).+    public var worksWithProposals: Set<UUID> { Set(held.keys) }++    public func isAttempted(_ key: ExtractionSourceKey) -> Bool { attempted.contains(key) }++    /// Exhaustion stops automatic work only (Req 1.11).+    public var budgetExhausted: Bool { budgetSpent >= CharacterExtractionBounds.runTimeBudget }++    /// The works `reconcile` has to ask the library about.+    public var trackedWorks: Set<UUID> {+        var tracked = Set(held.keys)+        tracked.formUnion(attempted.map(\.work))+        if let inFlight { tracked.insert(inFlight.key.work) }+        return tracked+    }++    // MARK: - Sweep++    public var sweepActive: Bool { sweepGate.isActive }++    /// The token for this sweep, or nil when a sweep coroutine is still+    /// running: one activation sweep at a time (Req 1.1).+    @discardableResult+    public mutating func beginSweep() -> Int? { sweepGate.begin() }++    /// Whether *this* sweep may still start attempts.+    public func isSweeping(generation: Int) -> Bool {+        sweepGate.isSweeping(generation: generation)+    }++    /// Ends the sweep the token identifies. A token from a sweep that has+    /// already ended is stale and ends nothing.+    public mutating func endSweep(generation: Int) { sweepGate.end(generation: generation) }++    private mutating func stopSweep() { sweepGate.stop() }++    // MARK: - Attempts++    /// The fingerprint is required: it is what `reconcile` compares the source+    /// against, and a missing one would read as "nothing known" and void the+    /// attempt on the very next reconcile.+    public mutating func start(+        _ key: ExtractionSourceKey, fingerprint: String, pass: ExtractionPassKind,+        environment: ModelWorkEnvironment+    ) -> ExtractionAttemptStart {+        switch pass {+        case .manual:+            // Req 1.11: the reader's pass starts regardless of coverage,+            // suppression, attempt memory and budget. What it will not do is+            // interrupt another reader-initiated pass.+            if let inFlight {+                guard inFlight.pass == .automatic else { return .refuse(.inFlight(inFlight.key)) }+                stopSweep()+                return .preempt(inFlight.key)+            }++        case .automatic:+            // In the order the sweep would hit them, each carrying the reason+            // the caller logs.+            if attempted.contains(key) { return .refuse(.attempted) }+            if budgetExhausted { return .refuse(.budgetExhausted) }+            if let inFlight { return .refuse(.inFlight(inFlight.key)) }+            if !environment.isActive { return .refuse(.notActive) }+            if environment.isLowPowerMode { return .refuse(.lowPower) }+            if environment.isThermallyConstrained { return .refuse(.thermallyConstrained) }+        }++        inFlight = InFlight(key: key, pass: pass, fingerprint: fingerprint)+        return .start+    }++    public mutating func settle(_ key: ExtractionSourceKey, _ settlement: ExtractionSettlement,+                                modelPhase: Duration) {+        let attempt = inFlight?.key == key ? inFlight : nil+        // Q101: an automatic attempt spends what it spent, the pre-empted ones+        // included. A manual pass is the reader's and spends none of the+        // sweep's budget.+        if attempt?.pass == .automatic { budgetSpent += modelPhase }+        let wasVoided = attempt?.voided == true+        if inFlight?.key == key { inFlight = nil }++        // A voided attempt was computed against a source that has since+        // changed, so its answer is discarded whatever it says, and the source+        // stays attemptable — exactly a `.cancelled` ending.+        guard !wasVoided else { return }++        switch settlement {+        case .proposals(let proposals):+            attempted.insert(key)+            guard !proposals.isEmpty else { return }+            hold(proposals, for: key.work)+        case .failed, .timedOut:+            attempted.insert(key)+        case .cancelled:+            break+        }+    }++    /// Q100: held rows are keyed by **name key within a work**, which is Q83's+    /// grain — one candidate or bundle per key per pass — carried across the+    /// per-source settles that build the list.+    ///+    /// Two sources of one work naming the same character appended two rows of+    /// one key, which showed the character twice and left `discard` removing+    /// only the first. So the newer assembly *replaces* the row for presentation+    /// — its name, target and aliases are the ones the later pass resolved — and+    /// the facts and cited revisions of both are aggregated, so nothing the+    /// earlier source contributed is lost and staleness still covers every+    /// revision the row rests on.+    private mutating func hold(_ proposals: [ExtractionProposal], for work: UUID) {+        var rows = held[work] ?? []+        for proposal in proposals {+            guard let index = rows.firstIndex(where: { $0.nameKey == proposal.nameKey }) else {+                rows.append(proposal)+                continue+            }+            rows[index] = Self.merged(rows[index], into: proposal)+        }+        held[work] = rows+    }++    /// `newer` as it will be shown, carrying `older`'s facts, aliases and cited+    /// revisions.+    private static func merged(+        _ older: ExtractionProposal, into newer: ExtractionProposal+    ) -> ExtractionProposal {+        var facts = newer.facts+        var seenFacts = Set(facts.map(\.identity))+        for fact in older.facts where seenFacts.insert(fact.identity).inserted {+            facts.append(fact)+        }+        facts.sort(by: CharacterExtractionAssembler.isOrderedBefore)++        var aliases = newer.proposedAliases+        var seenAliases = Set(newer.aliasKeys + [newer.nameKey])+        for alias in older.proposedAliases+        where seenAliases.insert(CharacterNameKey.normalize(alias)).inserted {+            aliases.append(alias)+        }++        var revisions = older.citedRevisions+        for (source, fingerprint) in newer.citedRevisions { revisions[source] = fingerprint }++        return ExtractionProposal(+            name: newer.name, nameKey: newer.nameKey, proposedAliases: aliases,+            target: newer.target, facts: facts, citedRevisions: revisions)+    }++    // MARK: - Decisions++    /// Drops the row the reader just decided. One row per name key per work+    /// (Q83's grain, kept across settles by Q100's merge), so the key identifies+    /// the row and one decision removes the whole of it.+    @discardableResult+    public mutating func discard(nameKey: String, for work: UUID) -> Bool {+        guard var proposals = held[work],+              let index = proposals.firstIndex(where: { $0.nameKey == nameKey })+        else { return false }+        proposals.remove(at: index)+        held[work] = proposals.isEmpty ? nil : proposals+        return true+    }++    public mutating func clearHeld(for work: UUID) {+        held[work] = nil+    }++    /// Q66/Q110: re-points a held row at the character the commit says it+    /// actually resolves onto.+    ///+    /// A `.reRouted` refusal carries the resolution back precisely so the sheet+    /// can re-present the row correctly. Without applying it, the refreshed list+    /// re-reads the *unchanged* row, shows it against the same target, and the+    /// next Keep earns the same refusal — the reader loops Keep → refuse → Keep+    /// for ever.+    @discardableResult+    public mutating func retarget(+        nameKey: String, for work: UUID, to characterID: UUID?+    ) -> Bool {+        guard var proposals = held[work],+              let index = proposals.firstIndex(where: { $0.nameKey == nameKey })+        else { return false }+        proposals[index].target = characterID.map { .existing($0) } ?? .newCharacter+        held[work] = proposals+        return true+    }++    // MARK: - Invalidation (Req 2.8)++    /// Compares what is held to the library as it stands now.+    ///+    /// A work absent from `works` has been deleted: its proposals and its+    /// attempt memory go with it. A held proposal goes when the character it+    /// targets is gone, or when **any** revision it cites has changed or+    /// vanished — staleness is per proposal (Req 2.7), so its siblings stay.+    ///+    /// Returns the works something was invalidated for; an in-flight attempt+    /// that was voided still has to be cancelled by the caller.+    @discardableResult+    public mutating func reconcile(against works: [UUID: WorkExtractionState]) -> Set<UUID> {+        var invalidated: Set<UUID> = []+        for work in trackedWorks {+            guard let state = works[work] else {+                if held[work] != nil { invalidated.insert(work) }+                held[work] = nil+                let dropped = attempted.filter { $0.work == work }+                if !dropped.isEmpty { invalidated.insert(work) }+                attempted.subtract(dropped)+                if inFlight?.key.work == work {+                    inFlight?.void()+                    invalidated.insert(work)+                }+                continue+            }++            if let proposals = held[work] {+                let surviving = proposals.filter { survives($0, in: state) }+                if surviving.count != proposals.count {+                    invalidated.insert(work)+                    held[work] = surviving.isEmpty ? nil : surviving+                }+            }+            if let inFlight, inFlight.key.work == work, !inFlight.voided,+               state.revisions[inFlight.key.source] != inFlight.fingerprint {+                self.inFlight?.void()+                invalidated.insert(work)+            }+        }+        return invalidated+    }++    private func survives(_ proposal: ExtractionProposal, in state: WorkExtractionState) -> Bool {+        if case .existing(let id) = proposal.target, !state.characterIDs.contains(id) {+            return false+        }+        return proposal.citedRevisions.allSatisfy { source, fingerprint in+            state.revisions[source] == fingerprint+        }+    }++    /// The run's cheap state goes; nothing durable is touched, because the+    /// ledger holds nothing durable. Returns the in-flight source the caller+    /// must cancel, if any.+    public mutating func memoryWarning() -> ExtractionSourceKey? {+        held.removeAll()+        attempted.removeAll()+        inFlight?.void()+        return inFlight?.key+    }++    /// Req 1.2: the sweep stops with the foreground, and its attempt with it. A+    /// manual pass is the reader's and continues.+    public mutating func resignActive() -> ExtractionSourceKey? {+        stopSweep()+        guard let inFlight, inFlight.pass == .automatic else { return nil }+        return inFlight.key+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift Added +194 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swiftnew file mode 100644index 0000000..68c1853--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift@@ -0,0 +1,194 @@+import AsterismCore+import Foundation++// The pipeline's own value types. What a fact cites (`SourceRef`) and what+// identifies one (`CharacterFactIdentity`) are **not** among them: they are+// AsterismCore's, because the store writes and reads exactly those values and a+// second spelling of either would let the pipeline and the store disagree about+// whether a fact is the same fact (Q99's reasoning, applied to the values as+// well as to the name key).++/// One model request's whole input (Req 1.4, Q42): the work's display title and+/// exactly one source's text. Nothing else from the library, and nothing from+/// outside it.+///+/// `fingerprint` is carried alongside but never sent: it is the revision the+/// resulting proposals are pinned to, so staleness can be judged per proposal+/// later (Req 2.7).+public struct ExtractionSource: Sendable, Equatable {+    public var workID: UUID+    public var workTitle: String+    public var source: SourceRef+    public var text: String+    public var fingerprint: String++    public init(workID: UUID, workTitle: String, source: SourceRef,+                text: String, fingerprint: String) {+        self.workID = workID+        self.workTitle = workTitle+        self.source = source+        self.text = text+        self.fingerprint = fingerprint+    }+}++/// One fact that survived grounding: a statement, the verbatim words it came+/// from, and the source that held them.+public struct GroundedFact: Sendable, Hashable {+    /// The key this fact is filed under. Set to the *resolved* character's+    /// retained key once the proposal routes onto one (Q79).+    public var nameKey: String+    public var statement: String+    /// Immutable after acceptance (Q74): editing it would change the identity+    /// triple and reopen dedup.+    public var quote: String+    public var source: SourceRef++    public init(nameKey: String, statement: String, quote: String, source: SourceRef) {+        self.nameKey = nameKey+        self.statement = statement+        self.quote = quote+        self.source = source+    }++    public var identity: CharacterFactIdentity {+        CharacterFactIdentity(nameKey: nameKey, source: source, quote: quote)+    }++    /// The same fact filed under another character's key.+    func keyed(to key: String) -> GroundedFact {+        var copy = self+        copy.nameKey = key+        return copy+    }+}++/// One candidate as grounding left it: still per source, not yet matched+/// against the work's characters.+public struct GroundedCandidate: Sendable, Equatable {+    public var name: String+    public var nameKey: String+    /// Components of a slash-compound name that each grounded (Decision 5).+    /// Shown on the review row and strikeable; never installed silently.+    public var proposedAliases: [String]+    public var source: SourceRef+    public var facts: [GroundedFact]++    public init(name: String, nameKey: String, proposedAliases: [String],+                source: SourceRef, facts: [GroundedFact]) {+        self.name = name+        self.nameKey = nameKey+        self.proposedAliases = proposedAliases+        self.source = source+        self.facts = facts+    }+}++/// A character the work already has, in the only terms matching cares about+/// (Req 2.3, Q67).+public struct ExistingCharacter: Sendable, Equatable {+    public var id: UUID+    /// The reader-editable name; its key is the first matching tier.+    public var name: String+    /// Minted at accept or create and kept through renames (Q19/Q46).+    public var retainedKey: String+    public var aliases: [String]++    public init(id: UUID, name: String, retainedKey: String, aliases: [String] = []) {+        self.id = id+        self.name = name+        self.retainedKey = retainedKey+        self.aliases = aliases+    }++    /// Every key this character answers to, for deduping proposed aliases.+    var matchKeys: Set<String> {+        var keys: Set<String> = [CharacterNameKey.normalize(name), retainedKey]+        keys.formUnion(aliases.map(CharacterNameKey.normalize))+        return keys+    }++    /// The same character in the store's matching terms. Tornness is not a+    /// matching input — the commit gate owns it (Req 2.8) — so it is false here.+    var matchTarget: CharacterMatchTarget {+        CharacterMatchTarget(+            id: id,+            currentNameKey: CharacterNameKey.normalize(name),+            retainedKey: retainedKey,+            aliasKeys: aliases.map(CharacterNameKey.normalize),+            isTorn: false)+    }+}++/// Everything the assembly filter needs about the library, read once under one+/// lock (Q78). Without it the filter has no input surface and each of its four+/// questions would be its own fetch.+public struct CharacterExtractionContext: Sendable, Equatable {+    public var characters: [ExistingCharacter]+    /// Identity triples already accepted. No pass re-proposes these — manual+    /// included (Req 1.7, Q49).+    public var acceptedFacts: Set<CharacterFactIdentity>+    /// Name keys the reader skipped or deleted. Blocks **new candidates only**,+    /// never bundles for an existing character (Q47).+    public var suppressedNameKeys: Set<String>+    /// Fact triples the reader unticked or deleted.+    public var suppressedFacts: Set<CharacterFactIdentity>++    public init(characters: [ExistingCharacter] = [],+                acceptedFacts: Set<CharacterFactIdentity> = [],+                suppressedNameKeys: Set<String> = [],+                suppressedFacts: Set<CharacterFactIdentity> = []) {+        self.characters = characters+        self.acceptedFacts = acceptedFacts+        self.suppressedNameKeys = suppressedNameKeys+        self.suppressedFacts = suppressedFacts+    }+}++/// Which kind of pass produced a proposal. The difference is suppression: the+/// sweep honours it, the manual pass exists to get past it (Req 1.11, Q49).+/// Accepted-fact dedup applies to both.+public enum ExtractionPassKind: String, Sendable, Hashable, CaseIterable {+    case automatic+    case manual+}++/// One row of the review list: a new candidate, or a bundle of additional+/// content for a character the work already has.+public struct ExtractionProposal: Sendable, Equatable {+    public enum Target: Sendable, Equatable {+        case newCharacter+        case existing(UUID)+    }++    public var name: String+    public var nameKey: String+    /// Displayed on the row and strikeable before accepting (Q92/Q96).+    public var proposedAliases: [String]+    public var target: Target+    public var facts: [GroundedFact]+    /// Every revision this row rests on, with the fingerprint it was derived+    /// against. A proposal is stale when **any** of them has changed (Req 2.7,+    /// Q83).+    public var citedRevisions: [SourceRef: String]++    public init(name: String, nameKey: String, proposedAliases: [String], target: Target,+                facts: [GroundedFact], citedRevisions: [SourceRef: String]) {+        self.name = name+        self.nameKey = nameKey+        self.proposedAliases = proposedAliases+        self.target = target+        self.facts = facts+        self.citedRevisions = citedRevisions+    }++    /// The keys the proposed aliases would install, in row order. Skipping+    /// suppresses the keys the row *displayed* at skip time, so the sheet+    /// subtracts the struck ones from `[nameKey] + aliasKeys` (Q92).+    public var aliasKeys: [String] { proposedAliases.map(CharacterNameKey.normalize) }++    public var isBundle: Bool {+        if case .existing = target { return true }+        return false+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift Added +176 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swiftnew file mode 100644index 0000000..ab38185--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift@@ -0,0 +1,176 @@+import AsterismCore+import Foundation++/// Why one piece of model output did not survive. Carried out rather than+/// re-derived so the diagnostics line names the rule that actually applied+/// (Req 1.9 keeps the *content* private; the reason is always readable).+public struct GroundingDrop: Sendable, Equatable {+    public enum Reason: String, Sendable, Equatable, CaseIterable {+        case emptyName+        case nameTooLong+        case nameNotInSource+        case emptyStatement+        case statementTooLong+        case emptyQuote+        case quoteTooLong+        case quoteNotVerbatim+        case candidateCap+        case factCap+    }++    public var name: String+    public var reason: Reason++    public init(name: String, reason: Reason) {+        self.name = name+        self.reason = reason+    }+}++public struct GroundingOutcome: Sendable, Equatable {+    public var candidates: [GroundedCandidate]+    public var drops: [GroundingDrop]++    public init(candidates: [GroundedCandidate] = [], drops: [GroundingDrop] = []) {+        self.candidates = candidates+        self.drops = drops+    }+}++/// The mechanical check that the model did not invent anything (Req 1.6, Q14).+///+/// Deterministic and pure, so the same response over the same source grounds+/// identically on any device — and so the prototype harness and the app can run+/// the same rules.+///+/// Three questions, in order: does the name appear in the source, does the+/// quote appear in it verbatim, and is everything inside the bounds. Comparison+/// is case-insensitive over NFC — the reader's note and the model's echo of it+/// differ in case and composition far more often than they differ in letters —+/// and never letter-insensitive: folding diacritics away would make Renée and+/// Renee the same person.+public enum CharacterGrounding {+    public static func ground(_ result: ExtractionResult,+                              from source: ExtractionSource) -> GroundingOutcome {+        let haystack = source.text.precomposedStringWithCanonicalMapping+        var outcome = GroundingOutcome()++        for character in result.characters {+            guard outcome.candidates.count < CharacterExtractionBounds.maximumCandidates else {+                outcome.drops.append(GroundingDrop(name: character.name, reason: .candidateCap))+                continue+            }+            guard let candidate = groundName(character.name, in: haystack, drops: &outcome.drops)+            else { continue }++            let facts = groundFacts(character.facts, nameKey: candidate.key,+                                    displayName: candidate.name, source: source.source,+                                    in: haystack, drops: &outcome.drops)+            outcome.candidates.append(GroundedCandidate(+                name: candidate.name, nameKey: candidate.key,+                proposedAliases: candidate.aliases, source: source.source, facts: facts))+        }+        return outcome+    }++    // MARK: - Names and the slash split (Decision 5)++    private struct GroundedName {+        var name: String+        var key: String+        var aliases: [String]+    }++    private static func groundName(_ raw: String, in haystack: String,+                                   drops: inout [GroundingDrop]) -> GroundedName? {+        let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)+        guard !name.isEmpty else {+            drops.append(GroundingDrop(name: raw, reason: .emptyName))+            return nil+        }++        // The split is tried first: "Hanna/Action Girl" is how the notes write+        // one character's two names, and it is almost never in the note as one+        // string. A component that does not ground cancels the split, and the+        // compound then stands or falls on the ordinary 1.6 check.+        if let split = split(name, in: haystack) { return split }++        guard name.count <= CharacterExtractionBounds.maximumNameLength else {+            drops.append(GroundingDrop(name: name, reason: .nameTooLong))+            return nil+        }+        guard contains(name, in: haystack) else {+            drops.append(GroundingDrop(name: name, reason: .nameNotInSource))+            return nil+        }+        return GroundedName(name: name, key: CharacterNameKey.normalize(name), aliases: [])+    }++    private static func split(_ name: String, in haystack: String) -> GroundedName? {+        guard name.contains("/") else { return nil }+        let components = name.split(separator: "/", omittingEmptySubsequences: false)+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }+        guard components.count >= 2 else { return nil }+        guard components.allSatisfy({+            !$0.isEmpty && $0.count <= CharacterExtractionBounds.maximumNameLength+                && contains($0, in: haystack)+        }) else { return nil }++        let head = components[0]+        let key = CharacterNameKey.normalize(head)+        var aliases: [String] = []+        var seen: Set<String> = [key]+        for component in components.dropFirst() {+            let aliasKey = CharacterNameKey.normalize(component)+            guard seen.insert(aliasKey).inserted else { continue }+            aliases.append(component)+        }+        return GroundedName(name: head, key: key, aliases: aliases)+    }++    // MARK: - Facts++    private static func groundFacts(+        _ facts: [ExtractedFact], nameKey: String, displayName: String,+        source: SourceRef, in haystack: String, drops: inout [GroundingDrop]+    ) -> [GroundedFact] {+        var kept: [GroundedFact] = []+        for fact in facts {+            guard kept.count < CharacterExtractionBounds.maximumFactsPerCandidate else {+                drops.append(GroundingDrop(name: displayName, reason: .factCap))+                continue+            }+            let quote = fact.quote.trimmingCharacters(in: .whitespacesAndNewlines)+            let statement = fact.statement.trimmingCharacters(in: .whitespacesAndNewlines)+            guard !quote.isEmpty else {+                drops.append(GroundingDrop(name: displayName, reason: .emptyQuote))+                continue+            }+            guard quote.count <= CharacterExtractionBounds.maximumEvidenceLength else {+                drops.append(GroundingDrop(name: displayName, reason: .quoteTooLong))+                continue+            }+            guard contains(quote, in: haystack) else {+                drops.append(GroundingDrop(name: displayName, reason: .quoteNotVerbatim))+                continue+            }+            guard !statement.isEmpty else {+                drops.append(GroundingDrop(name: displayName, reason: .emptyStatement))+                continue+            }+            guard statement.count <= CharacterExtractionBounds.maximumStatementLength else {+                drops.append(GroundingDrop(name: displayName, reason: .statementTooLong))+                continue+            }+            kept.append(GroundedFact(nameKey: nameKey, statement: statement,+                                     quote: quote, source: source))+        }+        return kept+    }++    /// `haystack` is expected to be NFC already; the needle is composed here.+    private static func contains(_ needle: String, in haystack: String) -> Bool {+        haystack.range(of: needle.precomposedStringWithCanonicalMapping,+                       options: [.caseInsensitive]) != nil+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift Added +182 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swiftnew file mode 100644index 0000000..12a9b45--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift@@ -0,0 +1,182 @@+import Foundation+import FoundationModels+import Synchronization++/// One statement about a character, and the words in the note that support it.+///+/// Flat strings, never an offset and never an identifier — the house pattern+/// `RuleProposal` set. Constrained decoding guarantees the shape but not that+/// the quote is really in the note, which is `CharacterGrounding`'s job+/// (Req 1.6, Q14/Q15).+@Generable+public struct ExtractedFact: Sendable, Equatable {+    @Guide(description: "One short statement about the character, in third person.")+    public var statement: String++    @Guide(description: "The exact words from the note this statement comes from, copied verbatim. Never paraphrase.")+    public var quote: String++    public init(statement: String = "", quote: String = "") {+        self.statement = statement+        self.quote = quote+    }+}++@Generable+public struct ExtractedCharacter: Sendable, Equatable {+    @Guide(description: "The character's name exactly as the note spells it.")+    public var name: String++    @Guide(description: "Facts the note states about this character. Empty if the note only mentions the name.")+    public var facts: [ExtractedFact]++    public init(name: String = "", facts: [ExtractedFact] = []) {+        self.name = name+        self.facts = facts+    }+}++@Generable+public struct ExtractionResult: Sendable, Equatable {+    @Guide(description: "Named story characters this note mentions. Empty if it names none.")+    public var characters: [ExtractedCharacter]++    public init(characters: [ExtractedCharacter] = []) {+        self.characters = characters+    }+}++/// The seam between the extraction pipeline and the on-device model.+/// Everything above it is testable on the host without Apple Intelligence.+public protocol CharacterExtractionModelClient: Sendable {+    /// Re-read on every activation: `.modelNotReady` is transient.+    func availability() -> ModelAvailability++    /// One source, one request (Req 1.4). Nothing else from the library+    /// travels, and nothing leaves the device.+    func extract(_ source: ExtractionSource) async throws -> ExtractionResult++    /// Whether the error is this client saying the input was too long. An+    /// oversized source is skipped and left uncovered rather than truncated+    /// (Req 1.5, Q35), and only the client knows what its own overflow looks+    /// like.+    func isContextWindowOverflow(_ error: any Error) -> Bool++    /// A loggable account of a failure, for the same reason: naming the *case*+    /// of a framework error takes the framework's own types.+    func describe(_ error: any Error) -> String+}++extension CharacterExtractionModelClient {+    public func isContextWindowOverflow(_ error: any Error) -> Bool { false }++    public func describe(_ error: any Error) -> String {+        SuggestionFailure.describe(error)+    }+}++// The stub is a test double, and the same gate the package's other fixtures+// carry keeps it out of a shipping build.+#if DEBUG || ASTERISM_PERFORMANCE_TESTING++/// A model client that returns what it was told to, for tests and for the UI+/// test launch environment.+///+/// Scripted the way `StubRuleSuggestionModelClient` is: one canned answer, or+/// `results` consumed one per call with the last entry repeating. `recorder`+/// keeps every source `extract` was given, so a test can assert both the count+/// and that a request carried nothing it should not have.+public struct StubCharacterExtractionModelClient: CharacterExtractionModelClient {+    public typealias ScriptedResult = Result<ExtractionResult, any Error & Sendable>++    /// The stub's only mutable state, behind a lock in a reference type so that+    /// copies of the (value-typed, `Sendable`) stub share one log and one+    /// position in the script.+    public final class Recorder: Sendable {+        private struct State {+            var sources: [ExtractionSource] = []+            var nextResult = 0+        }++        private let state = Mutex(State())++        public init() {}++        public var recordedSources: [ExtractionSource] { state.withLock { $0.sources } }+        public var callCount: Int { state.withLock { $0.sources.count } }++        public func reset() { state.withLock { $0 = State() } }++        fileprivate func record(_ source: ExtractionSource, scriptLength: Int) -> Int? {+            state.withLock { state in+                state.sources.append(source)+                defer { state.nextResult += 1 }+                guard scriptLength > 0 else { return nil }+                return min(state.nextResult, scriptLength - 1)+            }+        }+    }++    public var availabilityResult: ModelAvailability+    public var result: ExtractionResult?+    public var error: (any Error & Sendable)?+    /// Sleep before answering, so a test can exercise timeout, pre-emption and+    /// cancellation.+    public var delay: Duration?+    /// Answers consumed in order, the last one repeating. Takes precedence over+    /// `result` and `error` when non-empty.+    public var results: [ScriptedResult]+    public let recorder: Recorder++    public init(availability: ModelAvailability = .available,+                result: ExtractionResult? = nil,+                error: (any Error & Sendable)? = nil,+                delay: Duration? = nil,+                results: [ScriptedResult] = [],+                recorder: Recorder = Recorder()) {+        self.availabilityResult = availability+        self.result = result+        self.error = error+        self.delay = delay+        self.results = results+        self.recorder = recorder+    }++    public func availability() -> ModelAvailability { availabilityResult }++    public func extract(_ source: ExtractionSource) async throws -> ExtractionResult {+        // Recorded before the delay: a call that is cancelled or times out+        // still happened, and a test asserting "no model call" must see it.+        let scripted = recorder.record(source, scriptLength: results.count)+        if let delay {+            try await Task.sleep(for: delay)+        }+        if let scripted {+            return try results[scripted].get()+        }+        if let error {+            throw error+        }+        guard let result else {+            throw StubCharacterExtractionModelClientError.noCannedResult+        }+        return result+    }++    public func isContextWindowOverflow(_ error: any Error) -> Bool {+        (error as? StubCharacterExtractionModelClientError) == .contextWindowOverflow+    }+}++public enum StubCharacterExtractionModelClientError: Error, Sendable, Equatable {+    /// The stub was asked for a result it was never given.+    case noCannedResult+    /// The stub's stand-in for the model refusing an over-long source, so a+    /// test can drive Req 1.5 without `FoundationModels`.+    case contextWindowOverflow+    /// The stub's stand-in for a guardrail refusal (Q22): the source is+    /// skipped and the sweep continues (Req 1.8).+    case refused+}++#endif
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift Added +97 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swiftnew file mode 100644index 0000000..74757d3--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift@@ -0,0 +1,97 @@+import AsterismCore+import Foundation+import FoundationModels++/// The on-device model, behind the extraction pipeline's seam. Everything it+/// does runs on this device: no note text leaves it (Req 1.4).+///+/// One `LanguageModelSession` per call. There is no conversation to keep —+/// each source is an independent question — and a fresh session keeps the+/// token window to the instructions plus one note.+public struct FoundationCharacterExtractionModelClient: CharacterExtractionModelClient {+    public init() {}++    // MARK: - Availability (Req 1.11)++    public func availability() -> ModelAvailability {+        FoundationModelDiagnostics.availability(from: SystemLanguageModel.default.availability)+    }++    // MARK: - The call++    public func extract(_ source: ExtractionSource) async throws -> ExtractionResult {+        let session = LanguageModelSession(instructions: Self.instructions)+        // `Response<Content>` is not Sendable, so only its content leaves here.+        let response = try await session.respond(to: Self.prompt(for: source),+                                                 generating: ExtractionResult.self,+                                                 options: Self.generationOptions)+        return response.content+    }++    /// Greedy sampling. The prototype measured a fact-triple Jaccard of 1.00+    /// across two full runs under it (Decision 3), and that repeatability is+    /// what the dedup story leans on: a proposal the reader skipped must not+    /// come back reworded next launch.+    static let generationOptions = GenerationOptions(sampling: .greedy)++    public func isContextWindowOverflow(_ error: any Error) -> Bool {+        FoundationModelDiagnostics.isContextWindowOverflow(error)+    }++    public func describe(_ error: any Error) -> String {+        FoundationModelDiagnostics.describe(error)+    }++    // MARK: - Prompting++    /// The prototype's instructions, tightened by its findings (Q55).+    ///+    /// The junk tail it produced — "everyone", "the general", "redevelopment+    /// law" — was ~25% of candidates and is prompt-shaped, so the task is+    /// stated as **named story characters**, and the model is told what is not+    /// one. What the tightening misses, the review flow catches; what it must+    /// never do is invent, which is why the verbatim-quote rule is stated+    /// twice and grounding checks it anyway.+    static let instructions = """+    You extract named story characters from a reader's private note about one \+    chapter of a serial story. The note is informal and may be short.++    Report only characters the note itself names. Use no outside knowledge of \+    any story. Never invent a character, a name, or a fact.++    A character is a person or being in the story who is referred to by a name. \+    These are not characters: the reader, the author, groups and crowds \+    ("everyone", "the crew"), unnamed roles ("the general", "the innkeeper"), \+    places, objects, organisations, and abstractions. If the note names no \+    characters, return an empty list.++    Spell each name exactly as the note spells it, character for character.++    For every fact, copy the supporting words out of the note into the quote \+    field verbatim — the same characters, in the same order, with the same \+    spelling, punctuation and capitalisation. Do not paraphrase, translate, \+    correct or shorten them. A fact you cannot support with the note's own \+    words is a fact you must not report. A character the note only mentions by \+    name is reported with no facts.+    """++    /// The source as the model sees it: the work's display title and one+    /// source's text, and nothing else (Req 1.4, Q42).+    static func prompt(for source: ExtractionSource) -> String {+        """+        The story is titled "\(source.workTitle)". \(Self.sourceDescription(source.source)):++        \(source.text)++        List the named story characters this note mentions, with any facts it \+        states about them.+        """+    }++    private static func sourceDescription(_ source: SourceRef) -> String {+        switch source {+        case .entry: "The reader's note about one chapter"+        case .genericNotes: "The reader's general notes about the story"+        }+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationModelDiagnostics.swift Added +58 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationModelDiagnostics.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationModelDiagnostics.swiftnew file mode 100644index 0000000..1a4daf8--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationModelDiagnostics.swift@@ -0,0 +1,58 @@+import Foundation+import FoundationModels++/// The two things every `FoundationModels`-backed client in this package has to+/// say about the framework, in one place: whether the model is there, and which+/// case a failure was.+///+/// Both features need them and neither should own them — a second copy would+/// be a second answer to "is the model available", and the two would drift the+/// first time Apple adds a case.+enum FoundationModelDiagnostics {+    /// The reason is kept for logging only: the reader is never told the model+    /// is absent (rule suggestion Req 4.1, extraction Req 1.9).+    static func availability(from availability: SystemLanguageModel.Availability) -> ModelAvailability {+        switch availability {+        case .available:+            .available+        case .unavailable(let reason):+            switch reason {+            case .deviceNotEligible: .unavailable(reason: "deviceNotEligible")+            case .appleIntelligenceNotEnabled: .unavailable(reason: "appleIntelligenceNotEnabled")+            case .modelNotReady: .unavailable(reason: "modelNotReady")+            @unknown default: .unavailable(reason: "unknown")+            }+        }+    }++    /// `GenerationError`'s cases are the difference between "the model refused+    /// this input" and "Apple Intelligence is not there", and the case name is+    /// the only part of that which reads at a glance.+    static func caseName(of error: LanguageModelSession.GenerationError) -> String {+        switch error {+        case .exceededContextWindowSize: "exceededContextWindowSize"+        case .assetsUnavailable: "assetsUnavailable"+        case .guardrailViolation: "guardrailViolation"+        case .unsupportedGuide: "unsupportedGuide"+        case .unsupportedLanguageOrLocale: "unsupportedLanguageOrLocale"+        case .decodingFailure: "decodingFailure"+        case .rateLimited: "rateLimited"+        case .concurrentRequests: "concurrentRequests"+        case .refusal: "refusal"+        @unknown default: "unknown"+        }+    }++    static func describe(_ error: any Error) -> String {+        guard let generation = error as? LanguageModelSession.GenerationError else {+            return SuggestionFailure.describe(error)+        }+        return "GenerationError.\(caseName(of: generation)): \(String(describing: generation))"+    }++    static func isContextWindowOverflow(_ error: any Error) -> Bool {+        guard let generation = error as? LanguageModelSession.GenerationError else { return false }+        if case .exceededContextWindowSize = generation { return true }+        return false+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift Modified +6 / -31
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swiftindex dd44a24..3cb7b2c 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationRuleSuggestionModelClient.swift@@ -17,19 +17,10 @@ public struct FoundationRuleSuggestionModelClient: RuleSuggestionModelClient {     }      /// The reason is kept for logging only: Req 4.1 forbids telling the reader-    /// anything about the model being absent.+    /// anything about the model being absent. Shared with the extraction+    /// client, which must answer this question identically.     static func availability(from availability: SystemLanguageModel.Availability) -> ModelAvailability {-        switch availability {-        case .available:-            .available-        case .unavailable(let reason):-            switch reason {-            case .deviceNotEligible: .unavailable(reason: "deviceNotEligible")-            case .appleIntelligenceNotEnabled: .unavailable(reason: "appleIntelligenceNotEnabled")-            case .modelNotReady: .unavailable(reason: "modelNotReady")-            @unknown default: .unavailable(reason: "unknown")-            }-        }+        FoundationModelDiagnostics.availability(from: availability)     }      // MARK: - The call@@ -55,9 +46,7 @@ public struct FoundationRuleSuggestionModelClient: RuleSuggestionModelClient {     /// It is the client's job rather than the suggester's so that nothing above     /// this seam has to import `FoundationModels`.     public func isContextWindowOverflow(_ error: any Error) -> Bool {-        guard let generation = error as? LanguageModelSession.GenerationError else { return false }-        if case .exceededContextWindowSize = generation { return true }-        return false+        FoundationModelDiagnostics.isContextWindowOverflow(error)     }      /// The failure as the log should carry it. `GenerationError`'s cases are@@ -66,25 +55,11 @@ public struct FoundationRuleSuggestionModelClient: RuleSuggestionModelClient {     /// which reads at a glance — so it is named here, where the framework is     /// imported, rather than left to `String(describing:)` above the seam.     public func describe(_ error: any Error) -> String {-        guard let generation = error as? LanguageModelSession.GenerationError else {-            return SuggestionFailure.describe(error)-        }-        return "GenerationError.\(Self.caseName(of: generation)): \(String(describing: generation))"+        FoundationModelDiagnostics.describe(error)     }      static func caseName(of error: LanguageModelSession.GenerationError) -> String {-        switch error {-        case .exceededContextWindowSize: "exceededContextWindowSize"-        case .assetsUnavailable: "assetsUnavailable"-        case .guardrailViolation: "guardrailViolation"-        case .unsupportedGuide: "unsupportedGuide"-        case .unsupportedLanguageOrLocale: "unsupportedLanguageOrLocale"-        case .decodingFailure: "decodingFailure"-        case .rateLimited: "rateLimited"-        case .concurrentRequests: "concurrentRequests"-        case .refusal: "refusal"-        @unknown default: "unknown"-        }+        FoundationModelDiagnostics.caseName(of: error)     }      // MARK: - Prompting
Packages/AsterismCore/Sources/AsterismIntelligence/ModelLane.swift Added +241 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/ModelLane.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/ModelLane.swiftnew file mode 100644index 0000000..76cd216--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/ModelLane.swift@@ -0,0 +1,241 @@+import Foundation+import Synchronization++/// What a claim on the model lane is worth against the other claims.+///+/// There are two, and no more: the reader is waiting, or the reader is not+/// (Req 1.3, Q62).+public enum ModelLaneClass: String, Sendable, Hashable, CaseIterable {+    /// The reader is waiting on the answer: a rule-suggestion editor request,+    /// the manual extraction pass. Pre-empts background work and is itself+    /// never pre-empted.+    case interactive+    /// An activation sweep. Never pre-empts, always pre-emptible, and queues+    /// behind every interactive claim whenever it arrived.+    case background+}++extension ModelLaneClass {+    /// Rule suggestion's own origins, in lane terms: the sweep is background,+    /// and both reader-facing origins are interactive (Q68).+    public init(_ origin: Origin) {+        switch origin {+        case .background: self = .background+        case .open, .request: self = .interactive+        }+    }+}++/// Proof that its bearer holds the lane's single slot.+///+/// The slot comes back three ways — an explicit `ModelLane.release(_:)`, this+/// token's own `release()`, and its `deinit` — because a stranded slot would+/// stop *both* features' model work for the whole app run (Q77). Holding the+/// token for exactly as long as the work runs is therefore the whole contract:+/// drop it and the lane recovers by itself.+public final class LaneToken: Sendable {+    let id: UUID+    public let laneClass: ModelLaneClass+    private let lane: ModelLane+    private let isReleased = Mutex(false)++    init(id: UUID, laneClass: ModelLaneClass, lane: ModelLane) {+        self.id = id+        self.laneClass = laneClass+        self.lane = lane+    }++    /// Whether this call is the one that released the token. Idempotent, so a+    /// holder that releases explicitly is not released a second time by+    /// `deinit`.+    @discardableResult+    func claimRelease() -> Bool {+        isReleased.withLock { released in+            guard !released else { return false }+            released = true+            return true+        }+    }++    /// Gives the slot back without awaiting the lane. Use+    /// `await lane.release(token)` where the caller needs the next holder to+    /// have been granted by the time it returns; this is the form `deinit` can+    /// call.+    public func release() {+        guard claimRelease() else { return }+        // Locals only: `deinit` may not let `self` escape.+        let lane = self.lane+        let id = self.id+        Task { await lane.release(claim: id) }+    }++    deinit { release() }+}++/// The single authority on who may run on-device model work (Req 1.3, Q62/Q77).+///+/// One holder at a time, app-wide. Rule suggestion and character extraction+/// each keep their own ledger and their own budget; what they do **not** each+/// keep is a notion of what is in flight — two arbiters with two answers to+/// that was the hole this actor closes.+///+/// Arbitration is deliberately shallow:+///+/// - An interactive claim pre-empts a background holder by *asking* it to+///   yield (`onPreempt`) and then waiting for it to release. The lane never+///   seizes the slot: the holder settles itself on the way out, which is the+///   two-step protocol `RuleSuggestionCoordinator` already runs for its own+///   pre-emptions.+/// - Interactive claims never pre-empt each other and queue FIFO, so a manual+///   extraction pass cannot starve a rule-suggestion editor request and vice+///   versa.+/// - Background claims queue behind every interactive claim, whenever they+///   arrived, so the two activation sweeps alternate rather than interleave.+///+/// **Feature-internal priority stays feature-internal.** While rule suggestion+/// holds the slot its ledger may still swap its own attempt (`.request` over+/// `.open`); the slot does not change hands, so a queued extraction claim+/// cannot steal it mid-swap.+///+/// Waiting here is not a settlement: it charges no budget and enters no+/// attempt memory. The lane keeps no such state to charge.+public actor ModelLane {+    private struct Claim {+        let id: UUID+        let laneClass: ModelLaneClass+        let onPreempt: (@Sendable () -> Void)?+        let continuation: CheckedContinuation<Void, Never>+    }++    private struct Holder {+        let id: UUID+        let laneClass: ModelLaneClass+        let onPreempt: (@Sendable () -> Void)?+        /// Asked once. A second interactive claim behind the first must not+        /// cancel the holder twice.+        var yieldRequested = false+    }++    private var holder: Holder?+    private var interactiveQueue: [Claim] = []+    private var backgroundQueue: [Claim] = []+    /// Claims cancelled before they reached the queue. Without this a claim+    /// whose task is cancelled in the window between `acquire` and the+    /// continuation being installed would never be resumed.+    private var withdrawn: Set<UUID> = []++    /// The app's lane.+    ///+    /// A shared instance rather than a wiring parameter everywhere, because+    /// "app-wide" is the whole guarantee: a feature accidentally constructed+    /// with a lane of its own would arbitrate against nobody, and nothing+    /// would fail visibly. Tests build their own `ModelLane()`.+    public static let shared = ModelLane()++    public init() {}++    // MARK: - Reads++    /// What the current holder claimed as, or nil when the slot is free.+    public var holderClass: ModelLaneClass? { holder?.laneClass }+    public var interactiveWaiting: Int { interactiveQueue.count }+    public var backgroundWaiting: Int { backgroundQueue.count }++    // MARK: - Claiming++    /// Waits for the slot and returns the token that holds it.+    ///+    /// `onPreempt` is how a background holder is asked to stop: the lane calls+    /// it when an interactive claim arrives, and the holder is expected to+    /// cancel its work and release. A holder that passes none is simply waited+    /// out, which is what an interactive holder wants.+    ///+    /// Throws `CancellationError` when the *waiting* task is cancelled. A+    /// withdrawn claim leaves no trace: no slot, no queue entry, and nothing+    /// for either feature's ledger to record.+    public func acquire(+        _ laneClass: ModelLaneClass, onPreempt: (@Sendable () -> Void)? = nil+    ) async throws -> LaneToken {+        try Task.checkCancellation()+        let id = UUID()+        await withTaskCancellationHandler {+            await withCheckedContinuation { continuation in+                enqueue(Claim(id: id, laneClass: laneClass,+                              onPreempt: onPreempt, continuation: continuation))+            }+        } onCancel: {+            Task { await self.withdraw(id) }+        }+        // The claim may have been granted and cancelled in the same breath;+        // releasing an id the lane does not hold is a no-op, so this covers+        // both the granted and the withdrawn case.+        if Task.isCancelled {+            release(claim: id)+            throw CancellationError()+        }+        return LaneToken(id: id, laneClass: laneClass, lane: self)+    }++    /// Gives the slot back and grants it to the next claim, if any.+    public func release(_ token: LaneToken) {+        token.claimRelease()+        release(claim: token.id)+    }++    func release(claim id: UUID) {+        withdrawn.remove(id)+        guard holder?.id == id else { return }+        holder = nil+        if !interactiveQueue.isEmpty {+            grant(interactiveQueue.removeFirst())+        } else if !backgroundQueue.isEmpty {+            grant(backgroundQueue.removeFirst())+        }+    }++    // MARK: - Queue++    private func enqueue(_ claim: Claim) {+        // Cancelled before it got here: resume immediately and let `acquire`+        // throw.+        if withdrawn.remove(claim.id) != nil {+            claim.continuation.resume()+            return+        }+        guard let current = holder else {+            grant(claim)+            return+        }+        switch claim.laneClass {+        case .interactive:+            interactiveQueue.append(claim)+            // Ask, do not seize (Q77): the holder settles itself and releases.+            if current.laneClass == .background, !current.yieldRequested {+                holder?.yieldRequested = true+                current.onPreempt?()+            }+        case .background:+            backgroundQueue.append(claim)+        }+    }++    private func grant(_ claim: Claim) {+        holder = Holder(id: claim.id, laneClass: claim.laneClass, onPreempt: claim.onPreempt)+        claim.continuation.resume()+    }++    private func withdraw(_ id: UUID) {+        if let index = interactiveQueue.firstIndex(where: { $0.id == id }) {+            interactiveQueue.remove(at: index).continuation.resume()+            return+        }+        if let index = backgroundQueue.firstIndex(where: { $0.id == id }) {+            backgroundQueue.remove(at: index).continuation.resume()+            return+        }+        // Already the holder: `acquire` releases it on the way out. Otherwise+        // the claim has not been enqueued yet, and `enqueue` will find this.+        guard holder?.id != id else { return }+        withdrawn.insert(id)+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/ModelWorkEnvironment.swift Added +25 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/ModelWorkEnvironment.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/ModelWorkEnvironment.swiftnew file mode 100644index 0000000..da419ad--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/ModelWorkEnvironment.swift@@ -0,0 +1,25 @@+import Foundation++/// The device conditions background model work is gated on, plus whether the+/// app is active.+///+/// Passed in rather than read here so the feature ledgers stay pure value types+/// the host tests can drive. Shared by rule suggestion (Req 5.3 there) and+/// character extraction (Req 1.2): the gates are the device's, not a feature's.+public struct ModelWorkEnvironment: Sendable, Equatable {+    public var isActive: Bool+    public var isLowPowerMode: Bool+    public var thermalState: ProcessInfo.ThermalState++    public init(isActive: Bool = true, isLowPowerMode: Bool = false,+                thermalState: ProcessInfo.ThermalState = .nominal) {+        self.isActive = isActive+        self.isLowPowerMode = isLowPowerMode+        self.thermalState = thermalState+    }++    /// Serious or critical stops a sweep; fair does not.+    var isThermallyConstrained: Bool {+        thermalState.rawValue >= ProcessInfo.ThermalState.serious.rawValue+    }+}
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift Modified +14 / -40
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swiftindex 484918a..52fe3c7 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionLedger.swift@@ -1,26 +1,5 @@ import Foundation -/// The device conditions the sweep is gated on (Req 5.3) and whether the app is-/// active. They are passed in rather than read here so the ledger stays a pure-/// value type the host tests can drive.-public struct RuleSuggestionEnvironment: Sendable, Equatable {-    public var isActive: Bool-    public var isLowPowerMode: Bool-    public var thermalState: ProcessInfo.ThermalState--    public init(isActive: Bool = true, isLowPowerMode: Bool = false,-                thermalState: ProcessInfo.ThermalState = .nominal) {-        self.isActive = isActive-        self.isLowPowerMode = isLowPowerMode-        self.thermalState = thermalState-    }--    /// Req 5.3's gate: serious or critical stops the sweep, fair does not.-    var isThermallyConstrained: Bool {-        thermalState.rawValue >= ProcessInfo.ThermalState.serious.rawValue-    }-}- /// Why the ledger refused to start an attempt. Carried out of `start` rather /// than re-derived by the caller: the state that decided it can have moved on /// by the time the caller asks, and a log line that names a different reason@@ -116,15 +95,18 @@ public struct RuleSuggestionLedger: Sendable, Equatable {     public private(set) var budgetSpent: Duration = .zero     public private(set) var fingerprints: [String: CorpusFingerprint] = [:] +    /// The sweep generation state machine (Req 5.1), shared with the character+    /// extraction ledger — the two features run the same sweep shape.+    private var sweepGate = SweepGate()+     /// The sweep that may still start attempts, if any. `resignActive` and     /// pre-emption clear it: this is the sweep's **stop signal**.-    public private(set) var permittedSweep: Int?+    public var permittedSweep: Int? { sweepGate.permitted }     /// The sweep coroutine that has begun and not yet ended. This is the     /// **single-instance guard**, and it is deliberately not the same thing as     /// the stop signal: a stopped sweep is still running until it notices, and     /// starting a second one under it would put two sweeps in the same loop.-    public private(set) var runningSweep: Int?-    private var sweepGenerations = 0+    public var runningSweep: Int? { sweepGate.running }      public init() {} @@ -186,7 +168,7 @@ public struct RuleSuggestionLedger: Sendable, Equatable {     /// Whether the sweep may still start attempts. Read for its own sake; a     /// sweep asks `isSweeping(generation:)` about *itself*, because a sweep     /// still running after its stop signal must not be revived by a later one.-    public var sweepActive: Bool { permittedSweep != nil }+    public var sweepActive: Bool { sweepGate.isActive }      /// The token for this sweep, or nil when a sweep coroutine is still     /// running: one activation sweep at a time (Req 5.1).@@ -197,28 +179,20 @@ public struct RuleSuggestionLedger: Sendable, Equatable {     /// would switch off whichever sweep had started in the meantime, and that     /// sweep would abort having attempted nothing.     @discardableResult-    public mutating func beginSweep() -> Int? {-        guard runningSweep == nil else { return nil }-        sweepGenerations += 1-        runningSweep = sweepGenerations-        permittedSweep = sweepGenerations-        return sweepGenerations-    }+    public mutating func beginSweep() -> Int? { sweepGate.begin() }      /// Whether *this* sweep may still start attempts (Req 5.3).-    public func isSweeping(generation: Int) -> Bool { permittedSweep == generation }+    public func isSweeping(generation: Int) -> Bool {+        sweepGate.isSweeping(generation: generation)+    }      /// Ends the sweep the token identifies. A token from a sweep that has     /// already ended is stale and ends nothing.-    public mutating func endSweep(generation: Int) {-        guard runningSweep == generation else { return }-        runningSweep = nil-        if permittedSweep == generation { permittedSweep = nil }-    }+    public mutating func endSweep(generation: Int) { sweepGate.end(generation: generation) }      /// The stop signal on its own: the running coroutine is left alone, and     /// only it can end the sweep.-    private mutating func stopSweep() { permittedSweep = nil }+    private mutating func stopSweep() { sweepGate.stop() }      // MARK: - Attempts @@ -227,7 +201,7 @@ public struct RuleSuggestionLedger: Sendable, Equatable {     /// and invalidate the hostname on the very next reconcile.     public mutating func start(hostname: String, origin: Origin,                                fingerprint: CorpusFingerprint,-                               environment: RuleSuggestionEnvironment) -> AttemptStart {+                               environment: ModelWorkEnvironment) -> AttemptStart {         // Whatever asked for it, the work is already being done (Q16) — unless         // it has been voided, in which case its answer is already worthless and         // attaching would hand the caller nothing.
Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift Modified +39 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swiftindex e49902c..0818a0b 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/RuleSuggestionTypes.swift@@ -51,6 +51,45 @@ public struct AttemptTimeout: Error, Sendable, Equatable {     } } +/// The attempt wrapper both pipelines bound their model work with — **one+/// implementation**, next to the error it throws.+///+/// Runs `operation` against a sleeping sibling and takes whichever finishes+/// first. The loser is cancelled here and awaited on scope exit, so a timeout is+/// thrown only once the request has actually stopped — which is what lets the+/// caller release its lane token knowing nothing of its own is still running.+///+/// `startedAt` is the caller's, not this function's: the model phase is measured+/// from the request, and the caller needs the same instant to measure a+/// *successful* attempt with.+public func withAttemptTimeout<Value: Sendable>(+    _ timeout: Duration,+    startedAt started: ContinuousClock.Instant,+    operation: @escaping @Sendable () async throws -> Value+) async throws -> Value {+    let outcome = try await withThrowingTaskGroup(of: TimedAttempt<Value>.self) { group in+        group.addTask { .settled(try await operation()) }+        group.addTask {+            try await Task.sleep(for: timeout)+            return .timedOut+        }+        guard let first = try await group.next() else { throw CancellationError() }+        group.cancelAll()+        return first+    }+    switch outcome {+    case .settled(let value): return value+    case .timedOut: throw AttemptTimeout(modelPhase: started.duration(to: .now))+    }+}++/// Declared at file scope rather than inside `withAttemptTimeout`: a local type+/// cannot capture the function's generic parameter.+private enum TimedAttempt<Value: Sendable>: Sendable {+    case settled(Value)+    case timedOut+}+ /// Whether the on-device model can be asked for a proposal. The reason is /// carried for logging only — Req 4.1 forbids showing it to the reader. public enum ModelAvailability: Sendable, Equatable {
Packages/AsterismCore/Sources/AsterismIntelligence/SweepGate.swift Added +58 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/SweepGate.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/SweepGate.swiftnew file mode 100644index 0000000..42a8293--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/SweepGate.swift@@ -0,0 +1,58 @@+import Foundation++/// The activation sweep's generation state machine, shared by both ledgers.+///+/// It exists because the two things "a sweep is on" can mean have to be told+/// apart:+///+/// * `permitted` is the **stop signal** — the sweep that may still start+///   attempts. `resignActive` and a pre-emption clear it.+/// * `running` is the **single-instance guard** — the sweep coroutine that has+///   begun and not yet ended. A stopped sweep is still running until it+///   notices, and starting a second one under it would put two sweeps in the+///   same loop.+///+/// Without the generation, a sweep that was stopped and then returned from its+/// awaited attempt would call `end` on whichever sweep had started in the+/// meantime, and that sweep would abort having attempted nothing.+///+/// Value-typed and pure: the ledgers embed one and forward to it, so neither+/// carries its own copy of these five lines of arithmetic.+public struct SweepGate: Sendable, Equatable {+    /// The sweep that may still start attempts, if any.+    public private(set) var permitted: Int?+    /// The sweep coroutine that has begun and not yet ended.+    public private(set) var running: Int?+    private var generations = 0++    public init() {}++    /// Whether *some* sweep may still start attempts.+    public var isActive: Bool { permitted != nil }++    /// The token for this sweep, or nil when a sweep coroutine is still+    /// running: one activation sweep at a time.+    @discardableResult+    public mutating func begin() -> Int? {+        guard running == nil else { return nil }+        generations += 1+        running = generations+        permitted = generations+        return generations+    }++    /// Whether *this* sweep may still start attempts.+    public func isSweeping(generation: Int) -> Bool { permitted == generation }++    /// Ends the sweep the token identifies. A token from a sweep that has+    /// already ended is stale and ends nothing.+    public mutating func end(generation: Int) {+        guard running == generation else { return }+        running = nil+        if permitted == generation { permitted = nil }+    }++    /// The stop signal on its own: the running coroutine is left alone, and+    /// only it can end the sweep.+    public mutating func stop() { permitted = nil }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex 2874ce6..53dd657 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -576,7 +576,7 @@ struct BackupExportDegradedRefusalTests {             metadata: BackupV4Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))         let decoded = try BackupV4Codec.decode(encoded) -        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)         let container = try ModelContainer(for: schema, configurations: [configuration])
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex c22e4ef..76663ee 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -350,7 +350,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV6.self)+    let schema = Schema(versionedSchema: AsterismSchemaV7.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -359,7 +359,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV6MigrationPlan.self,+        migrationPlan: AsterismV7MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -373,7 +373,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV6.self)+    let schema = Schema(versionedSchema: AsterismSchemaV7.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -382,7 +382,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV6MigrationPlan.self,+        migrationPlan: AsterismV7MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV5ExportTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV5ExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV5ExportTests.swiftindex 5ae585b..9687320 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV5ExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV5ExportTests.swift@@ -289,7 +289,7 @@ struct BackupV5ExportTests {         let context: ModelContext          init() throws {-            let schema = Schema(versionedSchema: AsterismSchemaV6.self)+            let schema = Schema(versionedSchema: AsterismSchemaV7.self)             container = try ModelContainer(                 for: schema,                 configurations: [
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6ArchiveTests.swift Added +703 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6ArchiveTests.swiftnew file mode 100644index 0000000..473b662--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6ArchiveTests.swift@@ -0,0 +1,703 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Archive generation 6/7 (Req 6.1, 6.2, Q63): characters, their suppressions and+// their coverage join the six frozen arrays, and the file stays importable+// beside the generations already accepted.+//+// Three suites, because the generation has three surfaces and they fail+// differently: the codec answers for the wire shape and its refusals, the+// exporter for what the store projects into it, and the importer for what an+// archive does to a live library.++// MARK: - Codec++@Suite("Backup V6 codec")+struct BackupV6CodecTests {++    @Test("V6 encode/decode round-trips 6/7, the m4 gate, and the three new arrays")+    func roundTrip() throws {+        let payload = BackupV6Fixtures.payload()++        let decoded = try BackupV6Codec.decode(+            try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))++        #expect(decoded.backupFormatVersion == 6)+        #expect(decoded.databaseSchemaVersion == 7)+        #expect(decoded.capabilityGate == "m4")+        #expect(decoded.payload == payload)+        #expect(decoded.payload.characters == payload.characters)+        #expect(decoded.payload.suppressions == payload.suppressions)+        #expect(decoded.payload.coverage == payload.coverage)+    }++    /// Every field of a character is on the wire, including the fact's citation+    /// and its immutable quote — asserted after a real round-trip rather than+    /// trusted to `Codable`.+    @Test("A character's facts, aliases, note and keys survive the round-trip")+    func characterFieldsRoundTrip() throws {+        let facts = [+            BackupV6Fixtures.fact(),+            BackupV6Fixtures.fact(+                statement: "Knows the way through the pass.",+                quote: "knows the way", source: .genericNotes),+        ]+        let payload = BackupV6Fixtures.payload(+            characters: [+                BackupV6Fixtures.character(aliases: ["Klar", "The Guide"], facts: facts)+            ])++        let decoded = try BackupV6Codec.decode(+            try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))++        let character = try #require(decoded.payload.characters.first)+        #expect(character.name == "Grover")+        #expect(character.nameKey == "grover")+        #expect(character.aliases == ["Klar", "The Guide"])+        #expect(character.note == "The guide.")+        #expect(character.facts.count == 2)+        #expect(character.facts.contains { $0.source == .genericNotes })+        #expect(character.facts.contains { $0.source == .entry(BackupV6Fixtures.entryID) })+        #expect(character.facts.allSatisfy { $0.nameKey == "grover" })+    }++    @Test("Both suppression kinds round-trip with their status and action time")+    func suppressionKindsRoundTrip() throws {+        let rows = [+            BackupV6Fixtures.suppression(),+            BackupV6Fixtures.suppression(+                id: BackupV6Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+                source: .entry(BackupV6Fixtures.entryID), evidence: "promised to guide",+                status: .cleared),+        ]+        let payload = BackupV6Fixtures.payload(suppressions: rows)++        let decoded = try BackupV6Codec.decode(+            try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))++        #expect(decoded.payload.suppressions == rows)+    }++    // MARK: The two deliberate exemptions++    /// Q78: the exporter enumerates characters whole, so a character whose work+    /// has not arrived exports with a nil work reference rather than vanishing —+    /// and the validator has to let it through, or the backup refuses over a+    /// tolerated in-flight state (Req 6.7).+    @Test("A character with no work reference validates")+    func orphanCharacterValidates() throws {+        let payload = BackupV6Fixtures.payload(+            characters: [BackupV6Fixtures.character(id: BackupV6Fixtures.orphanID, workID: nil)])++        let decoded = try BackupV6Codec.decode(+            try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))++        #expect(decoded.payload.characters.first?.workID == nil)+    }++    /// Decision 2, pinned so it survives refactors: a fact's citation is+    /// tolerated when it dangles. The reader deleted the cited entry, or it has+    /// not synced — neither is corruption, and refusing here would fail the+    /// whole backup over routine curation.+    @Test("A fact citing an entry the archive does not carry validates")+    func danglingFactCitationValidates() throws {+        let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000001")!+        let payload = BackupV6Fixtures.payload(+            characters: [+                BackupV6Fixtures.character(facts: [BackupV6Fixtures.fact(source: .entry(absent))])+            ],+            suppressions: [+                BackupV6Fixtures.suppression(+                    id: BackupV6Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+                    source: .entry(absent), evidence: "gone")+            ])++        let decoded = try BackupV6Codec.decode(+            try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))++        #expect(decoded.payload.characters.first?.facts.first?.source == .entry(absent))+        #expect(decoded.payload.suppressions.first?.sourceEntryID == absent)+    }++    /// The other half of the character rule: optional, but **checked when+    /// present** — the `validateEntry` `workID` pattern.+    @Test("A character naming a work the archive does not carry refuses")+    func characterCitingAnAbsentWorkRefuses() throws {+        let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000002")!+        let payload = BackupV6Fixtures.payload(+            characters: [BackupV6Fixtures.character(workID: absent)])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    @Test("A suppression naming a work the archive does not carry refuses")+    func suppressionCitingAnAbsentWorkRefuses() throws {+        let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000003")!+        let payload = BackupV6Fixtures.payload(+            suppressions: [BackupV6Fixtures.suppression(workID: absent)])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    // MARK: Payloads that contradict themselves++    @Test("Two records for one character identity refuse")+    func duplicateCharacterIDRefuses() throws {+        let payload = BackupV6Fixtures.payload(+            characters: [BackupV6Fixtures.character(), BackupV6Fixtures.character()])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    @Test("Two records for one suppression identity refuse")+    func duplicateSuppressionIDRefuses() throws {+        let payload = BackupV6Fixtures.payload(+            suppressions: [BackupV6Fixtures.suppression(), BackupV6Fixtures.suppression()])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    /// Coverage is keyed by (source kind, record) — two fingerprints for one+    /// revision is a file that cannot say which one it means.+    @Test("Two coverage records for one source refuse")+    func duplicateCoverageRefuses() throws {+        let payload = BackupV6Fixtures.payload(+            coverage: [+                BackupV6Fixtures.entryCoverage(),+                BackupV6Fixtures.entryCoverage(fingerprint: "0000"),+            ])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    @Test("A coverage record whose source kind no build writes refuses")+    func unknownCoverageKindRefuses() throws {+        let payload = BackupV6Fixtures.payload(+            coverage: [+                BackupV6Coverage(+                    sourceKindRaw: "chapter", recordID: BackupV6Fixtures.entryID,+                    fingerprint: BackupV6Fixtures.noteFingerprint)+            ])++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(+                try BackupV6Codec.encode(payload: payload, metadata: BackupV6Fixtures.metadata()))+        }+    }++    @Test("A mismatched version pair around 6/7 is refused by the codec itself")+    func mismatchedPairsRefuse() throws {+        let encoded = try BackupV6Codec.encode(+            payload: BackupV6Fixtures.payload(), metadata: BackupV6Fixtures.metadata())+        var object = try #require(+            try JSONSerialization.jsonObject(with: encoded) as? [String: Any])+        object["databaseSchemaVersion"] = 6++        #expect(throws: BackupV6CodecError.self) {+            try BackupV6Codec.decode(try JSONSerialization.data(withJSONObject: object))+        }+    }+}++// MARK: - Export++@Suite("Backup V6 export", .serialized)+struct BackupV6ExportTests {+    private static let host = "characters.example"+    private static let workID = UUID(uuidString: "60000000-0000-4000-8000-000000000001")!+    private static let entryID = UUID(uuidString: "60000000-0000-4000-8000-000000000002")!+    private static let characterID = UUID(uuidString: "60000000-0000-4000-8000-000000000003")!+    private static let orphanID = UUID(uuidString: "60000000-0000-4000-8000-000000000004")!+    private static let early = Date(timeIntervalSince1970: 1_000_000)+    private static let note = "Grover promised to guide them home."+    private static let genericNotes = "The guide is not what he seems."++    @Test("Exporter produces a v6 filename and a valid, decodable 6/7 document")+    func exporterProducesValidDocument() async throws {+        let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)+        try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)+        defer { try? FileManager.default.removeItem(at: tempDir) }++        let payload = BackupV6Fixtures.payload()+        let exporter = BackupV6Exporter(+            repository: MockV6SnapshotProvider(payload: payload), stagingDirectory: tempDir)+        let result = try await exporter.export(metadata: BackupV6Fixtures.metadata())++        #expect(result.fileURL.lastPathComponent.contains("v6"))+        let decoded = try BackupV6Codec.decode(try Data(contentsOf: result.fileURL))+        #expect(decoded.backupFormatVersion == 6)+        #expect(decoded.databaseSchemaVersion == 7)+        #expect(decoded.payload == payload)+        exporter.cleanup(result)+    }++    /// Req 6.1: what the store holds is what the archive carries — the character+    /// with its facts, the suppression row, and both coverage shapes.+    @Test("Characters, suppressions and coverage project out of the store")+    func charactersProject() throws {+        let store = try LibraryStore()+        store.insertCharacter(+            id: Self.characterID, name: "Grover", aliases: ["Klar"], note: "The guide.",+            facts: [+                CharacterFact(+                    statement: "Promised to guide them home.",+                    quote: "promised to guide them home", nameKey: "grover",+                    source: .entry(Self.entryID))+            ])+        store.insertSuppression(nameKey: "the crowned one")+        store.coverEntry()+        store.coverGenericNotes()+        try store.context.save()++        let payload = try LibraryRepository.projectV6Payload(context: store.context)++        let character = try #require(payload.characters.first)+        #expect(character.id == Self.characterID)+        #expect(character.workID == Self.workID)+        #expect(character.nameKey == "grover")+        #expect(character.aliases == ["Klar"])+        #expect(character.facts.map(\.quote) == ["promised to guide them home"])++        let suppression = try #require(payload.suppressions.first)+        #expect(suppression.nameKey == "the crowned one")+        #expect(suppression.workID == Self.workID)+        #expect(suppression.kindRaw == CharacterSuppressionKind.candidate.rawValue)++        #expect(+            payload.coverage.contains {+                $0.sourceKindRaw == "entry" && $0.recordID == Self.entryID+                    && $0.fingerprint == CharacterCoverageFingerprint.of(Self.note)+            })+        #expect(+            payload.coverage.contains {+                $0.sourceKindRaw == "genericNotes" && $0.recordID == Self.workID+                    && $0.fingerprint == CharacterCoverageFingerprint.of(Self.genericNotes)+            })+    }++    /// Q78: enumerated whole, never works→children. A character that synced+    /// ahead of its work is inert in the app, but dropping it from the backup+    /// would be losing reader data to a timing accident.+    @Test("A character whose work has not arrived exports with a nil work reference")+    func orphanCharacterExports() throws {+        let store = try LibraryStore()+        store.insertCharacter(id: Self.orphanID, name: "Stranger", attachToWork: false)+        try store.context.save()++        let payload = try LibraryRepository.projectV6Payload(context: store.context)++        let orphan = try #require(payload.characters.first { $0.id == Self.orphanID })+        #expect(orphan.workID == nil)+        // And the file it produces is legal: the validator's exemption and the+        // exporter's enumeration have to agree, or the export refuses its own bytes.+        let encoded = try BackupV6Codec.encode(+            payload: payload, metadata: BackupV6Fixtures.metadata())+        #expect(try BackupV6Codec.decode(encoded).payload == payload)+    }++    /// Req 6.5. One character UUID over two rows that disagree about something+    /// the reader wrote is one record with two authored values, and an archive+    /// can hold neither of them honestly.+    @Test("A torn character group refuses the export")+    func tornCharacterRefusesExport() throws {+        let store = try LibraryStore()+        store.insertCharacter(id: Self.characterID, name: "Grover", note: "The guide.")+        store.insertCharacter(id: Self.characterID, name: "Grover", note: "A traitor.")+        try store.context.save()++        #expect(throws: BackupV6ExportError.self) {+            try LibraryRepository.projectV6Payload(context: store.context)+        }+    }++    /// Req 6.2 and Decision 2: the export succeeds while a fact's citation+    /// dangles. Deleting a cited entry is curation, not damage.+    @Test("The export succeeds while a fact's citation dangles")+    func danglingCitationExports() throws {+        let absent = UUID(uuidString: "60000000-0000-4000-8000-0000000000ff")!+        let store = try LibraryStore()+        store.insertCharacter(+            id: Self.characterID, name: "Grover",+            facts: [+                CharacterFact(+                    statement: "Was there.", quote: "was there", nameKey: "grover",+                    source: .entry(absent))+            ])+        try store.context.save()++        let payload = try LibraryRepository.projectV6Payload(context: store.context)++        #expect(payload.characters.first?.facts.first?.source == .entry(absent))+        let encoded = try BackupV6Codec.encode(+            payload: payload, metadata: BackupV6Fixtures.metadata())+        #expect(try BackupV6Codec.decode(encoded).payload == payload)+    }++    // MARK: - Fixture++    /// An in-memory V7 store holding one taught-enough Site, one Work with+    /// generic notes and one noted Entry. The container is retained for the+    /// test's lifetime: a `ModelContext` does not keep its container alive.+    private final class LibraryStore {+        let container: ModelContainer+        let context: ModelContext++        init() throws {+            let schema = Schema(versionedSchema: AsterismSchemaV7.self)+            container = try ModelContainer(+                for: schema,+                configurations: [+                    ModelConfiguration(+                        schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)+                ])+            context = ModelContext(container)+            let site = Site(hostname: BackupV6ExportTests.host, displayName: "Characters")+            site.mode = .untaught+            context.insert(site)++            let work = Work(+                id: BackupV6ExportTests.workID, displayTitle: "A Work",+                siteHostname: BackupV6ExportTests.host, timestamp: BackupV6ExportTests.early)+            work.genericNotes = BackupV6ExportTests.genericNotes+            context.insert(work)+            work.site = site++            let rawURL = "https://\(BackupV6ExportTests.host)/read/1"+            let entry = Entry(+                id: BackupV6ExportTests.entryID, captureTitle: "Chapter 1",+                captureTitleSource: .host, rawURLString: rawURL,+                hostname: BackupV6ExportTests.host, entryIdentityKey: rawURL,+                timestamp: BackupV6ExportTests.early, note: BackupV6ExportTests.note)+            entry.conservativeIdentityKey = rawURL+            entry.workAssignmentProvenanceRaw = FieldProvenanceKind.manual.rawValue+            context.insert(entry)+            entry.site = site+            entry.work = work+        }++        private var work: Work? {+            try? context.fetch(FetchDescriptor<Work>()).first+        }++        func insertCharacter(+            id: UUID, name: String, aliases: [String] = [], note: String = "",+            facts: [CharacterFact] = [], attachToWork: Bool = true+        ) {+            let character = CharacterRecord(+                id: id, name: name, nameKey: CharacterNameKey.normalize(name),+                aliases: aliases, note: note, facts: facts,+                timestamp: BackupV6ExportTests.early)+            context.insert(character)+            if attachToWork { character.work = work }+        }++        func insertSuppression(nameKey: String) {+            let row = CharacterSuppression(+                kind: .candidate, nameKey: nameKey, actionAt: BackupV6ExportTests.early)+            context.insert(row)+            row.work = work+        }++        func coverEntry() {+            try? context.fetch(FetchDescriptor<Entry>()).first?+                .characterExtractionFingerprint = CharacterCoverageFingerprint.of(+                    BackupV6ExportTests.note)+        }++        func coverGenericNotes() {+            work?.genericNotesExtractionFingerprint = CharacterCoverageFingerprint.of(+                BackupV6ExportTests.genericNotes)+        }+    }+}++// MARK: - Import++@Suite("Backup 6/7 import", .serialized)+struct BackupV6ImportTests {++    // MARK: Acceptance beside the generations already shipped++    @Test("The importer accepts 4/4, 5/6 and 6/7")+    func acceptedGenerations() throws {+        let v4 = try BackupV4Codec.encode(+            payload: BackupV4Fixtures.composedPayload(),+            metadata: BackupV4Metadata(appBuild: "test-4", exportedAt: BackupV6Fixtures.created))+        let v5 = try BackupV5Codec.encode(+            payload: BackupV5Fixtures.composedPayload(), metadata: BackupV5Fixtures.metadata())+        let v6 = try BackupV6Codec.encode(+            payload: BackupV6Fixtures.payload(), metadata: BackupV6Fixtures.metadata())++        #expect(try BackupImporter.plan(from: v4).metadata.formatVersion == 4)+        #expect(try BackupImporter.plan(from: v5).metadata.formatVersion == 5)++        let plan = try BackupImporter.plan(from: v6)+        #expect(plan.metadata.formatVersion == 6)+        #expect(plan.metadata.schemaVersion == 7)+        #expect(plan.payload == .v6Archive(BackupV6Fixtures.payload()))+    }++    @Test("A mismatched pair around 6/7 is unsupported")+    func mismatchedPairsReject() throws {+        for (format, schema) in [(6, 6), (6, 5), (5, 7), (7, 7)] {+            let data = try JSONSerialization.data(withJSONObject: [+                "backupFormatVersion": format,+                "databaseSchemaVersion": schema,+            ])+            #expect(throws: BackupImportError.self) {+                try BackupImporter.plan(from: data)+            }+        }+    }++    // MARK: What lands (Req 6.1)++    @Test("A 6/7 archive commits its characters, suppressions and coverage")+    func archiveCommits() async throws {+        let fixture = try await M5Fixture()++        let result = try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(BackupV6Fixtures.payload()))+        guard case .committed = result else {+            Issue.record("expected committed, got \(result)")+            return+        }++        let characters = try await fixture.repository.m5AllCharacters()+        let grover = try #require(characters.first { $0.id == BackupV6Fixtures.groverID })+        #expect(grover.name == "Grover")+        #expect(grover.nameKey == "grover")+        #expect(grover.aliases == ["Klar"])+        #expect(grover.note == "The guide.")+        #expect(grover.facts.map(\.quote) == ["promised to guide them home"])+        #expect(grover.facts.first?.source == .entry(BackupV6Fixtures.entryID))+        #expect(grover.workID == BackupV6Fixtures.workID, "the character joins its work")++        let suppressions = try await fixture.repository.m5SuppressionRows()+        let row = try #require(suppressions.first { $0.id == BackupV6Fixtures.suppressionID })+        #expect(row.nameKey == "the crowned one")+        #expect(row.kind == .candidate)+        #expect(row.status == .active)+        #expect(row.workID == BackupV6Fixtures.workID)++        #expect(+            try await fixture.repository.m5EntryCoverage(BackupV6Fixtures.entryID)+                == BackupV6Fixtures.noteFingerprint)+        #expect(+            try await fixture.repository.m5WorkCoverage(BackupV6Fixtures.workID)+                == BackupV6Fixtures.genericNotesFingerprint)+    }++    /// Req 6.7 through the archive: a character with no work is a tolerated+    /// in-flight state on the way out (Q78) and on the way in.+    @Test("An orphan character imports and stays unattached")+    func orphanCharacterImports() async throws {+        let fixture = try await M5Fixture()++        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(+                    characters: [+                        BackupV6Fixtures.character(id: BackupV6Fixtures.orphanID, workID: nil)+                    ])))++        let characters = try await fixture.repository.m5AllCharacters()+        let orphan = try #require(characters.first { $0.id == BackupV6Fixtures.orphanID })+        #expect(orphan.workID == nil)+    }++    /// Q81: coverage carries no timestamp to value-guard with, and needs none —+    /// a pair is kept exactly where the archived fingerprint still describes the+    /// source's current text, and dropped otherwise.+    @Test("Coverage is self-validating: a stale fingerprint is dropped")+    func coverageIsSelfValidating() async throws {+        let fixture = try await M5Fixture()++        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(+                    coverage: [+                        BackupV6Fixtures.entryCoverage(fingerprint: "not-this-note"),+                        BackupV6Fixtures.workCoverage(),+                    ])))++        #expect(try await fixture.repository.m5EntryCoverage(BackupV6Fixtures.entryID) == nil)+        #expect(+            try await fixture.repository.m5WorkCoverage(BackupV6Fixtures.workID)+                == BackupV6Fixtures.genericNotesFingerprint)+    }++    // MARK: Value guards and idempotence (Req 6.1, 7.7's shape)++    @Test("Importing the same 6/7 archive twice changes nothing the second time")+    func importingTwiceChangesNothing() async throws {+        let fixture = try await M5Fixture()+        let plan = BackupV6Fixtures.plan(BackupV6Fixtures.payload())++        try await fixture.repository.confirmImport(plan: plan)+        let charactersAfterFirst = try await fixture.repository.m5AllCharacters()+        let suppressionsAfterFirst = try await fixture.repository.m5SuppressionRows()++        try await fixture.repository.confirmImport(plan: plan)++        #expect(try await fixture.repository.m5AllCharacters() == charactersAfterFirst)+        #expect(try await fixture.repository.m5SuppressionRows() == suppressionsAfterFirst)+    }++    @Test("An archive older than the stored character writes nothing")+    func olderArchiveDoesNotRegressACharacter() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(BackupV6Fixtures.payload()))++        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(+                    characters: [+                        BackupV6Fixtures.character(+                            name: "Renamed by an older device", note: "older",+                            modifiedAt: BackupV6Fixtures.created.addingTimeInterval(-1_000))+                    ])))++        let grover = try #require(+            try await fixture.repository.m5AllCharacters()+                .first { $0.id == BackupV6Fixtures.groverID })+        #expect(grover.name == "Grover")+        #expect(grover.note == "The guide.")+    }++    @Test("An archive newer than the stored character updates every row of it")+    func newerArchiveUpdatesACharacter() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(BackupV6Fixtures.payload()))++        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(+                    characters: [+                        BackupV6Fixtures.character(+                            name: "Grover Underwood", note: "Still the guide.",+                            modifiedAt: BackupV6Fixtures.created.addingTimeInterval(1_000))+                    ])))++        let grover = try #require(+            try await fixture.repository.m5AllCharacters()+                .first { $0.id == BackupV6Fixtures.groverID })+        #expect(grover.name == "Grover Underwood")+        #expect(grover.note == "Still the guide.")+        // The retained key never moves with a rename (Q19/Q46) — including a+        // rename that arrives through an archive.+        #expect(grover.nameKey == "grover")+    }++    /// Req 6.6: suppression convergence is the reader's most recent action, and+    /// an archive is not exempt from it.+    @Test("A suppression older than the stored row does not undo a clear")+    func olderSuppressionDoesNotUndoAClear() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(+                    suppressions: [+                        BackupV6Fixtures.suppression(+                            status: .cleared,+                            actionAt: BackupV6Fixtures.created.addingTimeInterval(1_000))+                    ])))++        try await fixture.repository.confirmImport(+            plan: BackupV6Fixtures.plan(+                BackupV6Fixtures.payload(suppressions: [BackupV6Fixtures.suppression()])))++        let row = try #require(+            try await fixture.repository.m5SuppressionRows()+                .first { $0.id == BackupV6Fixtures.suppressionID })+        #expect(row.status == .cleared)+    }++    // MARK: Pre-feature archives (Req 6.1)++    @Test(+        "Importing an archive from before this feature succeeds with no characters",+        arguments: [5, 4])+    func preFeatureArchivesCreateNoCharacters(formatVersion: Int) async throws {+        let fixture = try await M5Fixture()++        let plan: BackupImportPlan+        if formatVersion == 5 {+            plan = try BackupImporter.plan(+                from: try BackupV5Codec.encode(+                    payload: BackupV5Fixtures.composedPayload(),+                    metadata: BackupV5Fixtures.metadata()))+        } else {+            plan = try BackupImporter.plan(+                from: try BackupV4Codec.encode(+                    payload: BackupV4Fixtures.composedPayload(),+                    metadata: BackupV4Metadata(+                        appBuild: "test-4", exportedAt: BackupV6Fixtures.created)))+        }+        try await fixture.repository.confirmImport(plan: plan)++        #expect(try await fixture.repository.m5AllCharacters().isEmpty)+        #expect(try await fixture.repository.m5SuppressionRows().isEmpty)+        #expect(try await fixture.repository.m5EntryCoverage(BackupV6Fixtures.entryID) == nil)+    }++    // MARK: The round trip (Req 6.1)++    /// The two halves meeting through the real exporter, the real codec and the+    /// real gate: a library holding characters, suppressions and coverage,+    /// exported and restored into a different one.+    @Test("A 6/7 archive exported from one library imports whole into another")+    func exportedArchivesRoundTrip() async throws {+        let source = try await M5Fixture()+        try await source.repository.confirmImport(+            plan: BackupV6Fixtures.plan(BackupV6Fixtures.payload()))++        let payload = try await source.repository.backupV6Snapshot()+        let plan = try BackupImporter.plan(+            from: try BackupV6Codec.encode(+                payload: payload, metadata: BackupV6Fixtures.metadata()))++        let target = try await M5Fixture()+        try await target.repository.confirmImport(plan: plan)++        let characters = try await target.repository.m5AllCharacters()+        let grover = try #require(characters.first { $0.id == BackupV6Fixtures.groverID })+        #expect(grover.name == "Grover")+        #expect(grover.facts.map(\.quote) == ["promised to guide them home"])+        #expect(grover.workID == BackupV6Fixtures.workID)+        #expect(+            try await target.repository.m5SuppressionRows()+                .contains { $0.id == BackupV6Fixtures.suppressionID })+        #expect(+            try await target.repository.m5EntryCoverage(BackupV6Fixtures.entryID)+                == BackupV6Fixtures.noteFingerprint)+    }+}++// MARK: - Test Doubles++private final class MockV6SnapshotProvider: BackupV6SnapshotProviding, @unchecked Sendable {+    let payload: BackupV6Payload+    init(payload: BackupV6Payload) { self.payload = payload }+    func backupV6Snapshot() async throws -> BackupV6Payload { payload }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6Fixtures.swift Added +173 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6Fixtures.swiftnew file mode 100644index 0000000..c3a3b2b--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV6Fixtures.swift@@ -0,0 +1,173 @@+import Foundation++@testable import AsterismCore++/// Shared builders for 6/7 payloads.+///+/// The six frozen arrays are `BackupV5Fixtures`' records (Q63): 6/7 adds the+/// three character arrays and re-freezes nothing. What this file rebuilds is the+/// composed fixture's Entry note and the Work's generic notes — coverage is+/// self-validating against the *text* (Q81), so a fixture whose sources are+/// empty could not tell a matching fingerprint from a mismatched one.+enum BackupV6Fixtures {+    static let created = BackupV5Fixtures.created+    static let workID = BackupV5Fixtures.composedWorkID+    /// The composed fixture's one Entry.+    static let entryID = UUID(uuidString: "22222222-2222-2222-2222-222222222222")!++    static let note = "Grover promised to guide them home."+    static let genericNotes = "The guide is not what he seems."+    static let noteFingerprint = CharacterCoverageFingerprint.of(note)+    static let genericNotesFingerprint = CharacterCoverageFingerprint.of(genericNotes)++    static let groverID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000001")!+    static let orphanID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000002")!+    static let strangerID = UUID(uuidString: "C4A2ACE0-0000-4000-8000-000000000003")!+    static let suppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000001")!+    static let factSuppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000002")!++    // MARK: - Records++    static func fact(+        statement: String = "Promised to guide them home.",+        quote: String = "promised to guide them home",+        nameKey: String = "grover",+        source: SourceRef = .entry(entryID)+    ) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    }++    static func character(+        id: UUID = groverID,+        workID: UUID? = workID,+        name: String = "Grover",+        nameKey: String = "grover",+        aliases: [String] = ["Klar"],+        note: String = "The guide.",+        facts: [CharacterFact] = [fact()],+        createdAt: Date = created,+        modifiedAt: Date = created+    ) -> BackupV6Character {+        BackupV6Character(+            id: id, workID: workID, name: name, nameKey: nameKey, aliases: aliases,+            note: note, facts: facts, createdAt: createdAt, modifiedAt: modifiedAt)+    }++    static func suppression(+        id: UUID = suppressionID,+        workID: UUID? = workID,+        kind: CharacterSuppressionKind = .candidate,+        nameKey: String = "the crowned one",+        source: SourceRef? = nil,+        evidence: String? = nil,+        status: CharacterSuppressionStatus = .active,+        actionAt: Date = created+    ) -> BackupV6Suppression {+        BackupV6Suppression(+            id: id, workID: workID, kindRaw: kind.rawValue, nameKey: nameKey,+            sourceKindRaw: source?.kindRaw, sourceEntryID: source?.entryID,+            evidence: evidence, statusRaw: status.rawValue, actionAt: actionAt)+    }++    /// Both coverage shapes the generation carries.+    static func entryCoverage(+        _ id: UUID = entryID, fingerprint: String = noteFingerprint+    ) -> BackupV6Coverage {+        BackupV6Coverage.entry(id, fingerprint: fingerprint)+    }++    static func workCoverage(+        _ id: UUID = workID, fingerprint: String = genericNotesFingerprint+    ) -> BackupV6Coverage {+        BackupV6Coverage.genericNotes(work: id, fingerprint: fingerprint)+    }++    // MARK: - Payloads++    /// The 5/6 composed payload lifted to 6/7, with a noted Entry, generic notes+    /// on the Work, and whatever character records the caller asks for.+    static func payload(+        characters: [BackupV6Character] = [character()],+        suppressions: [BackupV6Suppression] = [suppression()],+        coverage: [BackupV6Coverage] = [entryCoverage(), workCoverage()]+    ) -> BackupV6Payload {+        let base = BackupV5Fixtures.composedPayload()+        return BackupV6Payload(+            entries: base.entries.map { noted($0) },+            works: base.works.map { annotated($0) },+            sites: base.sites,+            titlePatterns: base.titlePatterns,+            urlRules: base.urlRules,+            workTypes: base.workTypes,+            characters: characters,+            suppressions: suppressions,+            coverage: coverage)+    }++    static func metadata(appBuild: String = "test-6", exportedAt: Date = created)+        -> BackupV6Metadata+    {+        BackupV6Metadata(appBuild: appBuild, exportedAt: exportedAt)+    }++    static func plan(_ payload: BackupV6Payload) -> BackupImportPlan {+        BackupImportPlan(+            metadata: BackupImportMetadata(+                formatVersion: 6, schemaVersion: 7, appBuild: "test-6", exportedAt: created,+                capabilityGate: "m4", entryCount: payload.entries.count,+                workCount: payload.works.count),+            payload: .v6Archive(payload),+            counts: LibraryRecordCounts(+                entries: payload.entries.count, works: payload.works.count,+                sites: payload.sites.count, titlePatterns: payload.titlePatterns.count,+                urlRulePatterns: payload.urlRules.count, workTypes: payload.workTypes.count))+    }++    // MARK: - Copies of the frozen records++    /// The composed Entry with a note. `BackupV4Entry`'s fields are `let`, so a+    /// copy is a full restatement — stated once here rather than in each suite.+    private static func noted(_ record: BackupV4Entry) -> BackupV4Entry {+        BackupV4Entry(+            id: record.id, captureTitle: record.captureTitle,+            captureTitleSource: record.captureTitleSource, rawURL: record.rawURL,+            canonicalURL: record.canonicalURL, hostname: record.hostname,+            entryIdentityKey: record.entryIdentityKey,+            identityKeyVersion: record.identityKeyVersion,+            conservativeIdentityKey: record.conservativeIdentityKey,+            identityBasis: record.identityBasis,+            identityURLRuleID: record.identityURLRuleID,+            identityURLRuleVersion: record.identityURLRuleVersion,+            identityNameTitleRuleID: record.identityNameTitleRuleID,+            identityNameTitleRuleVersion: record.identityNameTitleRuleVersion,+            urlWorkIdentity: record.urlWorkIdentity, urlWorkRuleID: record.urlWorkRuleID,+            urlWorkRuleVersion: record.urlWorkRuleVersion,+            chapterSequence: record.chapterSequence,+            chapterSequenceRuleID: record.chapterSequenceRuleID,+            chapterSequenceRuleVersion: record.chapterSequenceRuleVersion,+            chapterTitle: record.chapterTitle,+            chapterTitleProvenance: record.chapterTitleProvenance,+            note: note, rating: record.rating, firstCapturedAt: record.firstCapturedAt,+            lastSharedAt: record.lastSharedAt, modifiedAt: record.modifiedAt,+            workID: record.workID, workAssignmentProvenance: record.workAssignmentProvenance,+            workURLRuleID: record.workURLRuleID, workURLRuleVersion: record.workURLRuleVersion,+            workURLAssignmentKind: record.workURLAssignmentKind,+            workPatternID: record.workPatternID, workPatternVersion: record.workPatternVersion,+            intentionallyUnattached: record.intentionallyUnattached)+    }++    /// The composed Work with generic notes.+    private static func annotated(_ record: BackupV5Work) -> BackupV5Work {+        BackupV5Work(+            id: record.id, displayTitle: record.displayTitle,+            lastParsedTitle: record.lastParsedTitle, siteHostname: record.siteHostname,+            urlIdentity: record.urlIdentity, urlIdentityState: record.urlIdentityState,+            urlIdentityRuleID: record.urlIdentityRuleID,+            urlIdentityRuleVersion: record.urlIdentityRuleVersion,+            workURL: record.workURL, genericNotes: genericNotes,+            workTypeID: record.workTypeID, legacyType: record.legacyType,+            typeName: record.typeName, genreTags: record.genreTags,+            titleProvenance: record.titleProvenance, createdAt: record.createdAt,+            modifiedAt: record.modifiedAt, entryIDs: record.entryIDs)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift Modified +14 / -13
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swiftindex c432c5b..5218f73 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift@@ -71,7 +71,7 @@ struct BootstrapActionTests {      // MARK: - Req 2.3: the marker-lagging sequence -    @Test("A \"4\" marker populates the relationships, then republishes the marker at \"6\"")+    @Test("A \"4\" marker populates the relationships, then republishes the marker at \"7\"")     func markerLaggingPopulatesThenPublishes() async throws {         let root = try ActionRoot()         try await root.seedReadyLibrary(hostname: "lagging.example")@@ -86,7 +86,7 @@ struct BootstrapActionTests {             Issue.record("expected a ready library, got \(result)")             return         }-        #expect(try root.markerText() == "6", "the pass ran, so the marker is republished")+        #expect(try root.markerText() == "7", "the pass ran, so the marker is republished")         try root.expectRelationshipsPopulated()         withExtendedLifetime(root) {}     }@@ -96,13 +96,14 @@ struct BootstrapActionTests {     /// publishes and nothing else. Stripping the relationships first is what     /// makes "the pass did not run" observable — a `.markerLaggingV4`     /// classification would repopulate them.-    @Test("A \"5\" marker republishes at \"6\" without re-running the site pass")-    func markerLaggingAtFivePublishesWithoutThePass() async throws {+    @Test("A \"5\" or \"6\" marker republishes at \"7\" without re-running the site pass",+          arguments: ["5", "6"])+    func markerLaggingAtFivePublishesWithoutThePass(lagging: String) async throws {         let root = try ActionRoot()-        try await root.seedReadyLibrary(hostname: "window.example")-        try await root.insertEntry(hostname: "window.example")+        try await root.seedReadyLibrary(hostname: "window-\(lagging).example")+        try await root.insertEntry(hostname: "window-\(lagging).example")         try root.stripRelationships()-        try root.writeMarker("5\n")+        try root.writeMarker("\(lagging)\n")          let (result, repository) = try await LibraryRepository.openForApp(root.configuration)         await repository.shutdown()@@ -111,7 +112,7 @@ struct BootstrapActionTests {             Issue.record("expected a ready library, got \(result)")             return         }-        #expect(try root.markerText() == "6", "the update window closes on the next app launch")+        #expect(try root.markerText() == "7", "the update window closes on the next app launch")         try root.expectRelationshipsUnpopulated()         withExtendedLifetime(root) {}     }@@ -150,7 +151,7 @@ struct BootstrapActionTests {             Issue.record("expected the retry to converge, got \(result)")             return         }-        #expect(try root.markerText() == "6")+        #expect(try root.markerText() == "7")         try root.expectRelationshipsPopulated()         withExtendedLifetime(root) {}     }@@ -172,7 +173,7 @@ struct BootstrapActionTests {         // are not what "empty" means here: the guard asks whether anything of the         // *reader's* would be certified sight unseen.         #expect(result == .ready(.seededEmpty))-        #expect(try root.markerText() == "6",+        #expect(try root.markerText() == "7",                 "a crash between store creation and the marker is repaired, not terminal")         withExtendedLifetime(root) {}     }@@ -313,10 +314,10 @@ private enum RefusedState: String, CaseIterable, Sendable {         switch self {         case .storeRecordedBelowV5:             try root.installStoreRecordedAtFourZeroZero()-            try root.writeMarker("6\n")+            try root.writeMarker("7\n")         case .readinessMarkerWithoutAStore:             try root.createStoreDirectory()-            try root.writeMarker("6\n")+            try root.writeMarker("7\n")         case .historicalMarkerWithoutAStore:             try root.createStoreDirectory()             try root.writeHistoricalMarker()@@ -325,7 +326,7 @@ private enum RefusedState: String, CaseIterable, Sendable {             try root.writeMigrationArtefact()         case .markerRecordingAnUnknownVersion:             try await root.seedReadyLibrary(hostname: "unknown.example")-            try root.writeMarker("7\n")+            try root.writeMarker("8\n")         case .markerThatIsNotText:             try await root.seedReadyLibrary(hostname: "bytes.example")             try root.writeMarkerBytes(ActionRoot.nonUTF8MarkerBytes)
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift Modified +40 / -23
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swiftindex 09a8afb..182da4e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift@@ -81,11 +81,11 @@ struct BootstrapClassifierTests {      /// `bothMarkersV4Governs` as a classification: the historical marker is a     /// leftover, and the row that matches first wins.-    @Test("A \"6\" marker beside a stale historical marker classifies ready")+    @Test("A \"7\" marker beside a stale historical marker classifies ready")     func readyMarkerGovernsOverAHistoricalMarker() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()-        try root.writeMarker("6\n")+        try root.seedBornAtLiveStore()+        try root.writeMarker("7\n")         try root.writeHistoricalMarker()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -95,11 +95,11 @@ struct BootstrapClassifierTests {     /// Req 1.2 forbids *resuming from* a migration artefact, not tolerating one.     /// A certified library that still carries one is ready, and the artefact is     /// cleared after the open rather than being allowed to refuse it.-    @Test("A \"6\" marker beside a leftover migration artefact classifies ready")+    @Test("A \"7\" marker beside a leftover migration artefact classifies ready")     func readyMarkerGovernsOverALeftoverArtefact() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()-        try root.writeMarker("6\n")+        try root.seedBornAtLiveStore()+        try root.writeMarker("7\n")         try root.writeMigrationArtefact()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -113,7 +113,7 @@ struct BootstrapClassifierTests {     @Test("A store with no marker but a leftover artefact classifies as an unmarked store, not a failure")     func leftoverArtefactBesideAnUnmarkedStoreIsNotAFailure() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeMigrationArtefact()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .unmarkedStore)@@ -130,7 +130,7 @@ struct BootstrapClassifierTests {     @Test("SQLite companions with no main file are a present store, classified unmarked")     func companionsWithoutAMainFileAreAPresentStore() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try #require(root.exists(root.walURL), "expected a persistent write-ahead log")         try FileManager.default.removeItem(at: root.storeURL) @@ -154,7 +154,7 @@ struct BootstrapClassifierTests {         let root = try ClassifierRoot()         try root.createStoreDirectory()         switch kind {-        case .readinessMarker: try root.writeMarker("6\n")+        case .readinessMarker: try root.writeMarker("7\n")         case .historicalMarker: try root.writeHistoricalMarker()         case .migrationSidecar: try root.writeMigrationArtefact()         }@@ -170,7 +170,7 @@ struct BootstrapClassifierTests {     func overlappingOrphanedEvidenceNamesTheReadinessMarker() throws {         let root = try ClassifierRoot()         try root.createStoreDirectory()-        try root.writeMarker("6\n")+        try root.writeMarker("7\n")         try root.writeHistoricalMarker()         try root.writeMigrationArtefact() @@ -182,7 +182,7 @@ struct BootstrapClassifierTests {     @Test("A marker recording \"4\" over a present store classifies as lagging on the site pass")     func markerAtFourIsLagging() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeMarker("4\n")          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)@@ -197,7 +197,7 @@ struct BootstrapClassifierTests {     @Test("A marker recording \"5\" over a present store classifies as lagging on publication only")     func markerAtFiveIsLaggingOnPublicationOnly() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeMarker("5\n")          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)@@ -205,11 +205,26 @@ struct BootstrapClassifierTests {         withExtendedLifetime(root) {}     } +    /// Q80: the V6 → V7 conversion is the second lightweight stage+    /// `ModelContainer.init` runs, so a `"6"` library owes only its marker — the+    /// same shape as `"5"`, one generation on. It must not be classified+    /// `.markerLaggingV4`, which would re-run the site pass over every record.+    @Test("A marker recording \"6\" over a present store classifies as lagging on publication only")+    func markerAtSixIsLaggingOnPublicationOnly() throws {+        let root = try ClassifierRoot()+        try root.seedBornAtLiveStore()+        try root.writeMarker("6\n")++        #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)+                == .markerLaggingV6)+        withExtendedLifetime(root) {}+    }+     @Test("A marker no build understands classifies unrecognised",-          arguments: ["7\n", "3\n", "45\n", "", "four\n"])+          arguments: ["8\n", "3\n", "45\n", "", "four\n"])     func unrecognisedMarkerText(content: String) throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeMarker(content)          guard case .unrecognised = try LibraryRepository.classify(@@ -223,7 +238,7 @@ struct BootstrapClassifierTests {     @Test("A marker that is not readable text classifies unrecognised")     func nonUTF8MarkerBytes() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeMarkerBytes(ClassifierRoot.nonUTF8MarkerBytes)          guard case .unrecognised = try LibraryRepository.classify(@@ -240,7 +255,7 @@ struct BootstrapClassifierTests {     @Test("A store carrying only the historical marker classifies unrecognised")     func storeWithOnlyTheHistoricalMarkerIsUnrecognised() throws {         let root = try ClassifierRoot()-        try root.seedBornAtV6Store()+        try root.seedBornAtLiveStore()         try root.writeHistoricalMarker()          guard case .unrecognised = try LibraryRepository.classify(@@ -257,7 +272,7 @@ struct BootstrapClassifierTests {     func belowV5StoreIsRefused() throws {         let root = try ClassifierRoot()         try V4RecordedStoreFixture.install(at: root.storeURL)-        try root.writeMarker("6\n")+        try root.writeMarker("7\n")          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)                 == .belowV5(version: "4.0.0"),@@ -272,7 +287,7 @@ struct BootstrapClassifierTests {         // it, which is `.indeterminate` — and `.indeterminate` proceeds.         try root.createStoreDirectory()         try Data("not a database".utf8).write(to: root.storeURL, options: .atomic)-        try root.writeMarker("6\n")+        try root.writeMarker("7\n")         try #require(StoreMetadata.recordedVersion(at: root.storeURL) == .indeterminate)          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready,@@ -338,7 +353,8 @@ private struct Cell: Sendable, CustomStringConvertible {         case .four: try root.writeMarker("4\n")         case .five: try root.writeMarker("5\n")         case .six: try root.writeMarker("6\n")-        case .unrecognisedText: try root.writeMarker("7\n")+        case .seven: try root.writeMarker("7\n")+        case .unrecognisedText: try root.writeMarker("8\n")         case .nonUTF8: try root.writeMarkerBytes(ClassifierRoot.nonUTF8MarkerBytes)         }         if historicalMarker { try root.writeHistoricalMarker() }@@ -350,9 +366,10 @@ private struct Cell: Sendable, CustomStringConvertible {     func expectedState(recordedVersion: StoreMetadata.RecordedVersion) -> BootstrapState {         if case .below(let version) = recordedVersion { return .belowV5(version: version) }         let storePresent = family.isStorePresent-        if marker == .six, storePresent { return .ready }+        if marker == .seven, storePresent { return .ready }         if marker == .four, storePresent { return .markerLaggingV4 }         if marker == .five, storePresent { return .markerLaggingV5 }+        if marker == .six, storePresent { return .markerLaggingV6 }         if !storePresent {             if marker != .absent { return .orphanedEvidence(kind: .readinessMarker) }             if historicalMarker { return .orphanedEvidence(kind: .historicalMarker) }@@ -379,7 +396,7 @@ private enum StoreFamily: String, CaseIterable, Sendable { }  private enum MarkerAxis: String, CaseIterable, Sendable {-    case absent, four, five, six, unrecognisedText, nonUTF8+    case absent, four, five, six, seven, unrecognisedText, nonUTF8 }  /// What the seeded main file is meant to record. The expectation is derived from@@ -437,7 +454,7 @@ private final class ClassifierRoot {      /// A store the live schema created, with the write-ahead log Core Data keeps     /// beside it. Nothing about the seeding depends on a migration path.-    func seedBornAtV6Store() throws {+    func seedBornAtLiveStore() throws {         try createStoreDirectory()         let container = try LibraryRepository.openContainer(at: storeURL)         let context = ModelContext(container)@@ -451,7 +468,7 @@ private final class ClassifierRoot {         guard family != .absent else { return }         switch version {         case .atOrAboveV5:-            try seedBornAtV6Store()+            try seedBornAtLiveStore()         case .below:             try V4RecordedStoreFixture.install(at: storeURL)         case .indeterminate:
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift Modified +14 / -9
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swiftindex 09d2a38..a490553 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift@@ -40,9 +40,9 @@ import Testing @Suite("Bootstrap states, app role (re-homed)", .serialized) struct AppBootstrapStateTests { -    // MARK: - Req 2.2: the marker records "6" and the store is present+    // MARK: - Req 2.2: the marker records the current generation and the store is present -    @Test("A populated library whose marker records \"6\" validates and opens ready")+    @Test("A populated library whose marker records \"7\" validates and opens ready")     func readyMarkerOpensReady() async throws {         let root = try LibraryRoot()         try await root.seedReadyLibrary(hostname: "r.example")@@ -79,14 +79,14 @@ struct AppBootstrapStateTests {     func unrecognisedMarkerFailsClosed() async throws {         let root = try LibraryRoot()         try await root.seedReadyLibrary(hostname: "f.example")-        // "6" is the version the app publishes, so the unopenable future version+        // "7" is the version the app publishes, so the unopenable future version         // this pins is the one after it.-        try root.writeMarker("7\n")+        try root.writeMarker("8\n")          await #expect(throws: LibraryRepositoryError.self) {             try await LibraryRepository.openForApp(root.configuration)         }-        #expect(try root.markerBytes() == Data("7\n".utf8),+        #expect(try root.markerBytes() == Data("8\n".utf8),                 "a refused open leaves the marker's bytes alone (Req 2.8)")         #expect(root.exists(root.storeURL), "and leaves the store it refused in place")     }@@ -182,7 +182,7 @@ struct ExtensionBootstrapStateTests {         #expect(result == .ready(oneSite))     } -    /// Every state the containing app has not brought to a `"5"` marker, with the+    /// Every state the containing app has not brought to a `"7"` marker, with the     /// same assertion over all of them: the open fails, and the library's state is     /// byte-identical afterwards apart from the lock file the extension is allowed     /// to create.@@ -226,8 +226,11 @@ private enum PreCertificationState: String, CaseIterable, Sendable {     case storeWithMarkerLaggingAtFour     /// The update window of `configurable-work-types` Req 8.7: the app has been     /// updated and not yet launched, so the library still records `"5"`. The app-    /// republishes `"6"` on its next launch; the extension declines until then.+    /// republishes `"7"` on its next launch; the extension declines until then.     case storeWithMarkerLaggingAtFive+    /// The same window one generation on: `character-extraction`'s update+    /// window, where the library still records `"6"` (Q80).+    case storeWithMarkerLaggingAtSix      func seed(into root: LibraryRoot) async throws {         guard self != .nothingOnDisk else { return }@@ -244,11 +247,13 @@ private enum PreCertificationState: String, CaseIterable, Sendable {             try root.removeMarker()             try root.writeMigrationArtefact()         case .storeWithFutureMarker:-            try root.writeMarker("7\n")+            try root.writeMarker("8\n")         case .storeWithMarkerLaggingAtFour:             try root.writeMarker("4\n")         case .storeWithMarkerLaggingAtFive:             try root.writeMarker("5\n")+        case .storeWithMarkerLaggingAtSix:+            try root.writeMarker("6\n")         }     } }@@ -258,7 +263,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable { /// The bytes a certified library's readiness marker holds. Frozen persisted state /// — `FrozenLibraryPathTests` is where that is pinned; here it is the value the /// ready cases compare against.-private let readyMarkerBytes = "6\n"+private let readyMarkerBytes = "7\n"  /// The counts of a library seeded with exactly one `Site`. ///
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift Added +430 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swiftnew file mode 100644index 0000000..f90a688--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift@@ -0,0 +1,430 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// Req 6.4/6.5: characters in the duplicate/torn machinery.+///+/// The load-bearing claim is a *negative* one — distinct-UUID characters never+/// form a set, so nothing can auto-merge them (Q76) — and the positive one is+/// that same-UUID rows behave exactly like an Entry's or a Work's: converged+/// silently where they agree, torn to the resolution sheet where they do not.+@Suite("Character duplicate sets (Req 6.4, Q76)", .serialized)+struct CharacterDuplicateSetTests {++    private static let workID = UUID(uuidString: "0E000000-0000-4000-8000-000000000001")!+    private static let hanna = UUID(uuidString: "0E000000-0000-4000-8000-00000000000A")!+    private static let bruce = UUID(uuidString: "0E000000-0000-4000-8000-00000000000B")!++    private func seeded(+        characters: [M5SeedCharacter], suppressions: [M5SeedSuppression] = []+    ) 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")],+            characters: characters,+            suppressions: suppressions)+        return fixture+    }++    /// The whole of Q76 in one case: two characters with the same name, the same+    /// facts and the same work are still two records, because their UUIDs+    /// differ. Every other record type would bucket these together.+    @Test("Two distinct-UUID characters never form a set, however alike they are")+    func distinctUUIDsNeverFormASet() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.hanna, name: "Hanna", workID: Self.workID),+            M5SeedCharacter(id: Self.bruce, name: "Hanna", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.isEmpty,+                "content bucketing would auto-collapse these, which Req 6.4 forbids")+        withExtendedLifetime(fixture) {}+    }++    /// The corollary: every character set has exactly one member, so `.merge` is+    /// structurally unreachable rather than merely unused.+    @Test("Every character set has one member, so a collapse has no loser to delete")+    func setsAlwaysHaveOneMember() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "one", workID: Self.workID),+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "two", workID: Self.workID),+            M5SeedCharacter(id: Self.bruce, name: "Bruce", note: "a", workID: Self.workID),+            M5SeedCharacter(id: Self.bruce, name: "Bruce", note: "b", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.count == 2)+        for set in scan.characterSets {+            #expect(set.members.count == 1, "\(set.key): a character set is one identity group")+            #expect(set.key.memberIDs.count == 1)+            #expect(set.members.first?.rowCount == 2)+        }+        withExtendedLifetime(fixture) {}+    }++    @Test("Same-UUID rows agreeing about everything authored are silently resolvable")+    func agreeingRowsResolveSilently() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        let set = try #require(scan.characterSets.first)+        #expect(set.classification == .silentlyResolvable)+        #expect(!set.isTorn)+        withExtendedLifetime(fixture) {}+    }++    @Test("Same-UUID rows disagreeing about a note are torn, and the tear reaches the workload")+    func disagreeingRowsTear() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        let set = try #require(scan.characterSets.first)+        #expect(set.classification == .divergent)+        #expect(set.isTorn)+        #expect(set.variants.count == 2)++        let workload = DuplicateWorkload(scan: scan)+        #expect(workload.reviewItems.map(\.key) == [set.key])+        #expect(workload.reviewItems.first?.route == .sheet,+                "a character set has no Merge affordance — there is nothing to merge into")+        #expect(workload.reviewItems.first?.isTorn == true)+        withExtendedLifetime(fixture) {}+    }++    /// The facts blob participates in authored comparison, so two rows whose+    /// facts were edited apart are torn even when the name and note agree.+    @Test("Rows disagreeing only about a fact statement are torn")+    func factEditsTear() async throws {+        let entry = UUID(uuidString: "0E000000-0000-4000-8000-0000000000E1")!+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna",+                facts: [CharacterFact(+                    statement: "Leads the squad", quote: "she led", nameKey: "hanna",+                    source: .entry(entry))],+                workID: Self.workID),+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna",+                facts: [CharacterFact(+                    statement: "She led it", quote: "she led", nameKey: "hanna",+                    source: .entry(entry))],+                workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.first?.isTorn == true)+        withExtendedLifetime(fixture) {}+    }++    /// Two rows whose fact lists agree as sets but were written in different+    /// orders must **not** tear: comparison re-encodes canonically (Q75).+    @Test("Rows whose facts agree in a different order do not tear")+    func factOrderDoesNotTear() async throws {+        let one = CharacterFact(+            statement: "A", quote: "qa", nameKey: "hanna", source: .genericNotes)+        let other = CharacterFact(+            statement: "B", quote: "qb", nameKey: "hanna",+            source: .entry(UUID(uuidString: "0E000000-0000-4000-8000-0000000000E2")!))+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", facts: [one, other], workID: Self.workID),+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", facts: [other, one], workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.first?.isTorn == false)+        withExtendedLifetime(fixture) {}+    }++    /// `CharacterAuthoredContent` is never bare, so a "bare" arrival cannot+    /// silently join an existing variant the way an empty Entry row does.+    @Test("Character authored content is never bare")+    func contentIsNeverBare() {+        #expect(!CharacterAuthoredContent.bare.isBare)+        #expect(!CharacterAuthoredContent(name: "Hanna").isBare)+    }++    /// Req 6.7: a character whose work has not arrived is inert, not a set and+    /// not an error.+    @Test("A sync-orphaned character is tolerated and forms no set on its own")+    func orphanIsTolerated() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: Self.hanna, name: "Hanna", workID: nil),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.isEmpty)+        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(rows.count == 1)+        withExtendedLifetime(fixture) {}+    }++    /// The case order feeds `DuplicateSetKey`'s sort, so appending is not a+    /// stylistic choice.+    @Test("`.character` is the last DuplicateRecordType case")+    func characterIsAppendedLast() {+        #expect(DuplicateRecordType.allCases.last == .character)+        #expect(DuplicateRecordType.entry < DuplicateRecordType.character)+        #expect(DuplicateRecordType.urlRule < DuplicateRecordType.character)+    }+}++@Suite("Character group convergence and torn resolution (Req 6.5)", .serialized)+struct CharacterConvergenceTests {++    private static let workID = UUID(uuidString: "0E000000-0000-4000-8000-000000000101")!+    private static let hanna = UUID(uuidString: "0E000000-0000-4000-8000-00000000010A")!++    private func seeded(_ 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")],+            characters: characters)+        return fixture+    }++    /// A split group whose rows disagree because one of them is *stale* — an+    /// older row that never received an edit — converges to the presented+    /// variant without the reader, exactly as a rule group does.+    ///+    /// Note the seed is two rows with the *same* content in different physical+    /// spellings (unsorted aliases), which is a real sync shape: the comparison+    /// sorts, the convergence rewrites, and afterwards both rows hold the+    /// canonical form.+    @Test("A converging pass normalises every row of an agreeing group")+    func convergenceNormalisesEveryRow() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", aliases: ["Action Girl", "AG"],+                workID: Self.workID),+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", aliases: ["AG", "Action Girl"],+                workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.characterSets.first?.classification == .silentlyResolvable,+                "alias order is not a disagreement")++        _ = try await fixture.repository.reconcileAfterSync()+        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(rows.count == 2)+        #expect(Set(rows.map { $0.aliases }).count == 1, "the rows converged on one alias list")+        withExtendedLifetime(fixture) {}+    }++    /// The reconciler must never pick a variant for the reader: that is exactly+    /// the silent merge Req 6.4 forbids.+    @Test("A converging pass leaves a torn group torn")+    func convergenceLeavesTornGroupsAlone() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID),+        ])++        let outcome = try await fixture.repository.reconcileAfterSync().duplicates+        #expect(outcome.reviewSetKeys.contains {+            $0.recordType == .character && $0.memberIDs == [Self.hanna]+        }, "a torn character group is published as the reader's work")++        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(Set(rows.map(\.note)) == ["brave", "reckless"], "no variant was chosen for them")+        withExtendedLifetime(fixture) {}+    }++    /// The `.work` arm's write shape: chosen-only, no union, no note append.+    @Test("Resolving a torn character group writes the chosen variant to every row")+    func resolutionWritesChosenOnly() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+            M5SeedCharacter(+                id: Self.hanna, name: "Hanna", aliases: ["Action Girl"], note: "reckless",+                workID: Self.workID),+        ])+        let scan = try await fixture.repository.m5Scan()+        let key = try #require(scan.characterSets.first?.key)++        let contract = try await fixture.repository.projectDuplicateResolution(setKey: key)+        guard case .character(_, let variants, let fields, let preselected) = contract else {+            Issue.record("expected a character contract, got \(contract)")+            return+        }+        #expect(variants.count == 2)+        #expect(fields.contains(.name))+        #expect(fields.contains(.note))+        #expect(fields.contains(.aliases))+        #expect(!fields.contains(.facts), "the fact lists agree, so they are not a decision")++        let chosen = try #require(variants.first { $0.note == "reckless" })+        #expect(preselected == variants.first?.id)++        let outcome = try await fixture.repository.commitDuplicateResolution(+            contract, choosing: chosen.id)+        #expect(outcome == .committed(survivorID: Self.hanna))++        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(rows.count == 2, "resolution converges the rows; it never deletes one")+        #expect(Set(rows.map(\.note)) == ["reckless"])+        #expect(Set(rows.map { $0.aliases }).count == 1)+        #expect(rows.allSatisfy { $0.aliases == ["Action Girl"] },+                "the chosen variant's aliases are written, not a union of both")+        withExtendedLifetime(fixture) {}+    }++    /// Req 6.5's second half: a torn character refuses the backup export the way+    /// a torn Entry or Work does. Without the projection arm there is no site+    /// for that refusal at all.+    @Test("A torn character group refuses the backup export")+    func tornCharacterRefusesExport() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "brave", workID: Self.workID),+            M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID),+        ])++        await #expect(throws: BackupV5ExportError.self) {+            _ = try await fixture.repository.backupV5Snapshot()+        }+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Citation repointing on entry collapse (Req 3.6, Q85)", .serialized)+struct CharacterCitationRepointingTests {++    private static let workID = UUID(uuidString: "0E000000-0000-4000-8000-000000000201")!+    private static let survivor = UUID(uuidString: "0E000000-0000-4000-8000-00000000020A")!+    private static let loser = UUID(uuidString: "0E000000-0000-4000-8000-00000000020B")!+    private static let hanna = UUID(uuidString: "0E000000-0000-4000-8000-00000000020C")!++    private func fact(_ source: SourceRef) -> CharacterFact {+        CharacterFact(statement: "Leads", quote: "she led", nameKey: "hanna", source: source)+    }++    /// The whole point of Q85: rewriting one row of a group changes its authored+    /// bytes and would leave the group torn — a tear bookkeeping manufactured.+    @Test("The rewrite lands on every row of the group, so nothing false-tears")+    func rewriteFansOutAcrossTheGroup() async throws {+        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")],+            characters: [+                M5SeedCharacter(+                    id: Self.hanna, name: "Hanna", facts: [fact(.entry(Self.loser))],+                    workID: Self.workID),+                M5SeedCharacter(+                    id: Self.hanna, name: "Hanna", facts: [fact(.entry(Self.loser))],+                    workID: Self.workID),+            ],+            suppressions: [+                M5SeedSuppression(+                    workID: Self.workID, kind: .fact, nameKey: "hanna",+                    source: .entry(Self.loser), evidence: "she led"),+            ])++        try await fixture.repository.repointCharacterCitations(+            from: Self.loser, to: Self.survivor, workID: Self.workID)++        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(rows.count == 2)+        #expect(rows.allSatisfy { $0.facts.map(\.source) == [.entry(Self.survivor)] })+        #expect(Set(rows.map { $0.factsData }).count == 1, "the group did not tear")++        let suppressions = try await fixture.repository.m5SuppressionRows()+        #expect(suppressions.map(\.source) == [.entry(Self.survivor)],+                "a suppression's source reference follows the surviving row too")+        withExtendedLifetime(fixture) {}+    }++    /// Decision 2: a citation that dangles for another reason is a tolerated+    /// state, not something the repointing repairs or drops.+    @Test("A citation naming no collapsed row is left exactly as it was")+    func unrelatedCitationsAreUntouched() async throws {+        let other = UUID(uuidString: "0E000000-0000-4000-8000-00000000020F")!+        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")],+            characters: [+                M5SeedCharacter(+                    id: Self.hanna, name: "Hanna",+                    facts: [fact(.entry(other)), fact(.genericNotes)],+                    workID: Self.workID),+            ])++        try await fixture.repository.repointCharacterCitations(+            from: Self.loser, to: Self.survivor, workID: Self.workID)++        let rows = try await fixture.repository.m5CharacterRows(id: Self.hanna)+        #expect(rows.first?.facts.map(\.source) == [.genericNotes, .entry(other)])+        withExtendedLifetime(fixture) {}+    }++    /// The reconciler derives the stamp from the collapsing Entries rather than+    /// a clock (Q56), so it is routinely *older* than the character it rewrites.+    /// `CharacterGroup.modifiedAt` is what `BackupV6Character` carries as its+    /// import value guard, so a backwards stamp would let an archive taken+    /// before the character's last edit overwrite it.+    @Test("Repointing never moves a character's modifiedAt backwards")+    func modifiedAtNeverRegresses() async throws {+        let edited = Date(timeIntervalSince1970: 1_800_009_000)+        let collapse = Date(timeIntervalSince1970: 1_800_000_100)+        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")],+            characters: [+                M5SeedCharacter(+                    id: Self.hanna, name: "Hanna", facts: [fact(.entry(Self.loser))],+                    workID: Self.workID, modifiedAt: edited),+            ])++        try await fixture.repository.repointCharacterCitations(+            from: Self.loser, to: Self.survivor, workID: Self.workID, timestamp: collapse)++        let rows = try await fixture.repository.m5AllCharacters()+        #expect(rows.first?.facts.map(\.source) == [.entry(Self.survivor)],+                "the citation still moved")+        #expect(rows.first?.modifiedAt == edited)+        withExtendedLifetime(fixture) {}+    }+}++// MARK: - Driving the repointing from a suite++private extension LibraryRepository {+    /// Runs the collapse repointing directly, which is what the two collapse+    /// paths do inside their own transactions. Seeding a genuine Entry duplicate+    /// set and letting the reconciler collapse it would exercise the same three+    /// lines through several hundred of somebody else's.+    func repointCharacterCitations(+        from loser: UUID, to survivor: UUID, workID: UUID,+        timestamp: Date = Date(timeIntervalSince1970: 1_800_000_100)+    ) async throws {+        try await withLockedContext(mode: .exclusive, operation: "repointing citations") { context in+            let works = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            CharacterCitationRepointing.repoint(+                survivors: [loser: survivor], in: works, timestamp: timestamp)+            try context.save()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift Added +524 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swiftnew file mode 100644index 0000000..68dd457--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift@@ -0,0 +1,524 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// The edit-mode half (Req 3.2, 3.3, 3.7, 5.3): one repository call per session,+// staged operations in the order performed, and a whole-step refusal on any+// basis mismatch (Q73/Q97).++private let epoch = M5Fixture.epoch+private let workA = UUID(uuidString: "1A000000-0000-4000-8000-00000000000A")!+private let workB = UUID(uuidString: "1A000000-0000-4000-8000-00000000000B")!+private let entry1 = UUID(uuidString: "1A000000-0000-4000-8000-000000000101")!+private let alex = UUID(uuidString: "1A000000-0000-4000-8000-000000000201")!+private let terawatt = UUID(uuidString: "1A000000-0000-4000-8000-000000000202")!++private func fact(+    _ statement: String, _ quote: String, _ source: SourceRef, key: String+) -> CharacterFact {+    CharacterFact(statement: statement, quote: quote, nameKey: key, source: source)+}++private func basis(_ id: UUID, _ rows: [CharacterAuthoredContent]) throws -> CharacterEditBasis {+    let content = try #require(rows.first)+    return CharacterEditBasis(characterID: id, content: content)+}++private func seeded(+    characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []+) async throws -> M5Fixture {+    let fixture = try await M5Fixture()+    try await fixture.repository.seedM5Rows(+        sites: [M5SeedSite(hostname: "c.example")],+        works: [M5SeedWork(+            id: workA, displayTitle: "Serial A", hostname: "c.example",+            genericNotes: "the cast")],+        entries: [M5SeedEntry(+            id: entry1, captureTitle: "Ch 1", hostname: "c.example", path: "1",+            note: "Alex is Terawatt", workID: workA)],+        characters: characters,+        suppressions: suppressions)+    return fixture+}++@Suite("The character edit step (Q73, Q97)", .serialized)+struct CharacterEditStepTests {++    @Test("Hand-creation mints the key from the typed name and clears its suppression")+    func creationMintsAndClears() async throws {+        let fixture = try await seeded(suppressions: [+            M5SeedSuppression(workID: workA, nameKey: "alex", status: .active, actionAt: epoch),+        ])++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA, operations: [.create(CharacterDraft(name: "The Alex", note: "hand"))])+        guard case .committed(let ids) = outcome, let id = ids.first else {+            Issue.record("expected a created character, got \(outcome)")+            return+        }++        let rows = try await fixture.repository.m5CharacterRows(id: id)+        #expect(rows.first?.name == "The Alex")+        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.match(nameKey: "alex")?.id == id,+                "the key is minted from the typed name at commit (Q46)")+        #expect(candidate.suppressions.candidateKeys.isEmpty, "Q44: creation clears the key")+        withExtendedLifetime(fixture) {}+    }++    @Test("An edit writes name, note and fact statements to every row of the group")+    func editFansOutAcrossTheGroup() async throws {+        let stored = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),+        ])+        let rows = try await fixture.repository.m5CharacterRows(id: alex)+        var edited = stored+        edited.statement = "Leads the team"++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.update(+                basis: try basis(alex, rows),+                draft: CharacterDraft(name: "Alexandra", note: "renamed", facts: [edited]))])+        #expect(outcome == .committed(characterIDs: [alex]))++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        #expect(after.count == 2)+        #expect(after.allSatisfy { $0.name == "Alexandra" && $0.note == "renamed" })+        #expect(after.allSatisfy { $0.facts.map(\.statement) == ["Leads the team"] })+        #expect(Set(after.map { $0.factsData }).count == 1, "the group did not tear")+        withExtendedLifetime(fixture) {}+    }++    /// Q74: the quote is the identity, and editing it would reopen dedup. An+    /// edit surface can move statements and nothing else.+    @Test("A draft cannot author a new quote, and dropping a fact suppresses its triple")+    func factDropSuppresses() async throws {+        let stored = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),+        ])+        let rows = try await fixture.repository.m5CharacterRows(id: alex)++        _ = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.update(+                basis: try basis(alex, rows),+                draft: CharacterDraft(+                    name: "Alex",+                    facts: [fact("Invented", "a quote nobody wrote", .genericNotes, key: "alex")]))])++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        #expect(after.first?.facts.isEmpty == true,+                "the invented fact is ignored and the stored one, absent from the draft, is deleted")+        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.suppressions.factIdentities == [stored.identity])+        withExtendedLifetime(fixture) {}+    }++    /// Q50: a rename-then-delete would otherwise re-propose the character under+    /// the deleted name, and aliases own absorbed names' routing.+    @Test("Deleting suppresses the retained, current and alias keys and every fact")+    func deletionSuppressesEveryKey() async throws {+        let stored = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: alex, name: "Alexandra", nameKey: "alex", aliases: ["Terawatt"],+                facts: [stored], workID: workA),+        ])+        let rows = try await fixture.repository.m5CharacterRows(id: alex)++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA, operations: [.delete(basis: try basis(alex, rows))])+        #expect(outcome == .committed(characterIDs: [alex]))++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.characters.isEmpty)+        #expect(candidate.suppressions.candidateKeys == ["alex", "alexandra", "terawatt"])+        #expect(candidate.suppressions.factIdentities == [stored.identity])+        withExtendedLifetime(fixture) {}+    }++    /// Q73: partial character commits would be unreviewable, so a mismatch on+    /// the second operation must undo the first.+    @Test("A basis mismatch refuses the whole step, naming the character")+    func basisMismatchRefusesTheWholeStep() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        let stale = CharacterEditBasis(+            characterID: alex, name: "Somebody Else", note: "", aliases: [], facts: [])++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [+                .create(CharacterDraft(name: "Bruce")),+                .update(basis: stale, draft: CharacterDraft(name: "Nope")),+            ])+        #expect(outcome == .refused(.basisMismatch(characterID: alex, name: "Somebody Else")))++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.characters.map(\.id) == [alex],+                "the create in the same step was rolled back with the refusal")+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn character refuses the step rather than being written over")+    func tornCharacterRefuses() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", note: "one", workID: workA),+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", note: "two", workID: workA),+        ])+        let stale = CharacterEditBasis(+            characterID: alex, name: "Alex", note: "one", aliases: [], facts: [])++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.update(basis: stale, draft: CharacterDraft(name: "Alex", note: "three"))])+        #expect(outcome == .refused(.torn(characterID: alex, name: "Alex")))+        withExtendedLifetime(fixture) {}+    }++    /// Req 5.3/Q104: the edit mode's read-only gate is the work's, and a tear+    /// can sync in while the editor sits open — so it is re-checked inside the+    /// transaction, exactly as `commitCharacterDecision` re-checks it. Nothing+    /// of the step is written, the create included.+    @Test("A torn work refuses the whole edit step")+    func tornWorkRefusesTheStep() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "one"),+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "two"),+            ])++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA, operations: [.create(CharacterDraft(name: "Alex"))])++        #expect(outcome == .refused(.workTorn))+        #expect(try await fixture.repository.m5AllCharacters().isEmpty)+        withExtendedLifetime(fixture) {}+    }++    /// Q97: a combine followed by an edit of the target must see the combined+    /// record, so the order is the reader's, not the type's.+    @Test("Staged operations apply in the order performed")+    func operationsApplyInOrder() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedCharacter(id: terawatt, name: "Terawatt", nameKey: "terawatt", workID: workA),+        ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [+                .combine(+                    source: try basis(terawatt, terawattRows),+                    target: try basis(alex, alexRows)),+                .update(+                    basis: CharacterEditBasis(+                        characterID: alex, name: "Alex", note: "", aliases: ["Terawatt"],+                        facts: []),+                    draft: CharacterDraft(+                        name: "Alex", note: "after the combine", aliases: ["Terawatt"])),+            ])+        #expect(outcome == .committed(characterIDs: [alex, alex]))++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        #expect(after.first?.note == "after the combine")+        #expect(after.first?.aliases == ["Terawatt"])+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Combining characters (Decision 4)", .serialized)+struct CharacterCombineTests {++    /// Q91: the source's **match keys**, deduped against the target's own. A+    /// renamed source's retained key survives only as a bare string, and losing+    /// it would re-manufacture the duplicate the combine fixes.+    @Test("The alias union carries the source's current name, retained key and aliases")+    func aliasUnionCarriesEveryMatchKey() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terrawatt", nameKey: "terawatt", aliases: ["TW", "Alex"],+                workID: workA),+        ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)++        _ = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.combine(+                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        // Authored content sorts its aliases, so the comparison is over the set+        // the reader's copies must agree on rather than an insertion order.+        #expect(Set(after.first?.aliases ?? []) == ["Terrawatt", "TW", "terawatt"],+                "\"Alex\" dedups against the target's own name; the bare retained key survives")++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.characters.map(\.id) == [alex])+        #expect(candidate.match(nameKey: "terrawatt")?.id == alex)+        #expect(candidate.match(nameKey: "terawatt")?.id == alex,+                "a later proposal under the absorbed name routes to the combined character")+        withExtendedLifetime(fixture) {}+    }++    /// Q94/Q98: an identity duplicate drops, except where the statements were+    /// edited apart — dropping one of those would contradict Req 3.7.+    @Test("Facts move re-keyed; duplicates drop but edited-apart copies both survive")+    func factsMoveAndDedup() async throws {+        let shared = "Alex is Terawatt"+        let fixture = try await seeded(characters: [+            M5SeedCharacter(+                id: alex, name: "Alex", nameKey: "alex",+                facts: [+                    fact("Is Terawatt", shared, .entry(entry1), key: "alex"),+                    fact("Wears blue", "blue coat", .genericNotes, key: "alex"),+                ],+                workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt",+                facts: [+                    // Same source and quote: re-keyed, this is the same triple.+                    fact("Is Terawatt", shared, .entry(entry1), key: "terawatt"),+                    // Same triple, different statement — edited apart.+                    fact("Also called Alex", shared, .entry(entry1), key: "terawatt"),+                ],+                workID: workA),+        ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)++        _ = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.combine(+                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let facts = try #require(after.first?.facts)+        #expect(facts.count == 3, "one duplicate dropped, the edited-apart copy survived")+        #expect(facts.allSatisfy { $0.nameKey == "alex" }, "re-keyed to the target (Q79)")+        #expect(Set(facts.map(\.statement))+                == ["Is Terawatt", "Also called Alex", "Wears blue"])+        withExtendedLifetime(fixture) {}+    }++    /// Q94: orphaned source-keyed rows would resurrect unticked facts the next+    /// time a pass proposed them.+    @Test("The source's active fact suppressions re-key to the target")+    func suppressionsRekey() async throws {+        let fixture = try await seeded(+            characters: [+                M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", workID: workA),+                M5SeedCharacter(+                    id: terawatt, name: "Terawatt", nameKey: "terawatt", workID: workA),+            ],+            suppressions: [+                M5SeedSuppression(+                    workID: workA, kind: .fact, nameKey: "terawatt",+                    source: .entry(entry1), evidence: "a quote"),+            ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)++        _ = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.combine(+                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10).first)+        #expect(candidate.suppressions.factIdentities+                == [CharacterFactIdentity(+                    nameKey: "alex", source: .entry(entry1), quote: "a quote")])+        #expect(candidate.suppressions.candidateKeys.isEmpty,+                """+                Decision 4: a combine writes no new suppressions, or the alias routing \+                it exists for would be fought by the deletion rule+                """)+        withExtendedLifetime(fixture) {}+    }++    @Test("The source's note is appended under a divider and its rows delete whole")+    func noteAppendsAndSourceDeletes() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", note: "target", workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "source",+                workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "source",+                workID: workA),+        ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)++        _ = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.combine(+                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])++        let after = try await fixture.repository.m5CharacterRows(id: alex)+        #expect(after.first?.note == "target" + CharacterNoteAppend.divider + "source")+        #expect(try await fixture.repository.m5CharacterRows(id: terawatt).isEmpty,+                "the source group deletes whole, or the combine tears it")+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn source or target refuses the combine")+    func tornGatesBothSides() async throws {+        let fixture = try await seeded(characters: [+            M5SeedCharacter(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "one", workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "two", workID: workA),+        ])+        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)++        let outcome = try await fixture.repository.commitCharacterEdits(+            workID: workA,+            operations: [.combine(+                source: CharacterEditBasis(+                    characterID: terawatt, name: "Terawatt", note: "one", aliases: [], facts: []),+                target: try basis(alex, alexRows))])+        #expect(outcome == .refused(.torn(characterID: terawatt, name: "Terawatt")))+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Characters through work merge, deletion and entry detail (Req 3.4, 5.4)", .serialized)+struct CharacterWorkIntegrationTests {++    private func twoWorks() async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Source", hostname: "c.example",+                    lastParsedTitle: "Source", genericNotes: "source notes"),+                M5SeedWork(+                    id: workB, displayTitle: "Target", hostname: "c.example",+                    lastParsedTitle: "Target", genericNotes: "target notes"),+            ],+            entries: [M5SeedEntry(+                id: entry1, captureTitle: "Ch 1", hostname: "c.example", path: "1",+                note: "Alex is Terawatt", workID: workA)])+        return fixture+    }++    @Test("A merge moves the source's characters, unions suppressions and resets coverage")+    func mergeMovesCharacters() async throws {+        let fixture = try await twoWorks()+        try await fixture.repository.seedM5Rows(+            characters: [M5SeedCharacter(+                id: alex, name: "Alex", nameKey: "alex",+                facts: [+                    fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex"),+                    fact("Introduced", "source notes", .genericNotes, key: "alex"),+                ],+                workID: workA)],+            suppressions: [M5SeedSuppression(workID: workA, nameKey: "ghost")])+        // Cover the target's generic notes, so the reset is observable.+        _ = try await fixture.repository.advanceCharacterCoverage(+            workID: workB,+            sources: [CharacterCompletedSource(+                ref: .genericNotes,+                fingerprint: CharacterCoverageFingerprint.of("target notes"))])++        let contract = try await fixture.repository.projectMerge(+            sourceWorkID: workA, targetWorkID: workB)+        #expect(contract.outcome.movedCharacterCount == 1, "the preview names what moves")+        let outcome = try await fixture.repository.commitMerge(contract)+        guard case .committed = outcome else {+            Issue.record("expected a committed merge, got \(outcome)")+            return+        }++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 10)+                .first { $0.workID == workB })+        #expect(candidate.characters.map(\.id) == [alex], "the character moved to the target")+        #expect(candidate.suppressions.candidateKeys == ["ghost"], "suppressions are unioned")+        #expect(candidate.uncoveredSources.contains { $0.ref == .genericNotes },+                "the target's coverage resets so a later sweep revisits it")++        let rows = try await fixture.repository.m5CharacterRows(id: alex)+        #expect(rows.first?.facts.map(\.source) == [.genericNotes, .entry(entry1)],+                "entry citations survive intact and the generic-notes citation now names the target")+        withExtendedLifetime(fixture) {}+    }++    @Test("Deleting a work deletes its characters and suppressions with it")+    func deletionCascades() async throws {+        let fixture = try await twoWorks()+        try await fixture.repository.seedM5Rows(+            characters: [M5SeedCharacter(+                id: alex, name: "Alex", nameKey: "alex", workID: workA)],+            suppressions: [M5SeedSuppression(workID: workA, nameKey: "ghost")])++        let contract = try await fixture.repository.projectWorkDeletion(workID: workA)+        let outcome = try await fixture.repository.commitWorkDeletion(+            contract, disposition: .deleteEntries, disclosedVariants: nil)+        #expect(outcome == .committed)++        #expect(try await fixture.repository.m5CharacterRows(id: alex).isEmpty)+        #expect(try await fixture.repository.m5SuppressionRows().isEmpty,+                "an orphaned suppression would be inert for ever and in every backup")+        withExtendedLifetime(fixture) {}+    }++    /// Req 5.4, populated in the one locked context the detail is built in.+    @Test("Entry detail names the characters citing that entry")+    func entryDetailNamesCitingCharacters() async throws {+        let fixture = try await twoWorks()+        try await fixture.repository.seedM5Rows(characters: [+            M5SeedCharacter(+                id: alex, name: "Alex", nameKey: "alex",+                facts: [+                    fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex"),+                    fact("Also", "Alex is", .entry(entry1), key: "alex"),+                ],+                workID: workA),+            M5SeedCharacter(+                id: terawatt, name: "Terawatt", nameKey: "terawatt",+                facts: [fact("Elsewhere", "source notes", .genericNotes, key: "terawatt")],+                workID: workA),+        ])++        let detail = try await fixture.repository.entryTeachingDetail(id: entry1)+        #expect(detail.citingCharacters.map(\.id) == [alex])+        #expect(detail.citingCharacters.first?.name == "Alex")+        #expect(detail.citingCharacters.first?.factCount == 2)+        withExtendedLifetime(fixture) {}+    }++    @Test("An entry nothing cites carries no citing characters")+    func entryDetailIsEmptyWhereNothingCites() async throws {+        let fixture = try await twoWorks()+        let detail = try await fixture.repository.entryTeachingDetail(id: entry1)+        #expect(detail.citingCharacters.isEmpty)+        withExtendedLifetime(fixture) {}+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift Added +669 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swiftnew file mode 100644index 0000000..d24e1a6--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift@@ -0,0 +1,669 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// The store half of extraction: the one locked read that supplies the filter's+// whole input (Q78), and the decision commit that is the only path by which+// model-proposed content reaches the library (Req 2.2).++private let epoch = M5Fixture.epoch+private let workA = UUID(uuidString: "0F000000-0000-4000-8000-00000000000A")!+private let workB = UUID(uuidString: "0F000000-0000-4000-8000-00000000000B")!+private let entry1 = UUID(uuidString: "0F000000-0000-4000-8000-000000000101")!+private let entry2 = UUID(uuidString: "0F000000-0000-4000-8000-000000000102")!+private let hanna = UUID(uuidString: "0F000000-0000-4000-8000-000000000201")!+private let bruce = UUID(uuidString: "0F000000-0000-4000-8000-000000000202")!++private func fact(+    _ statement: String, _ quote: String, _ source: SourceRef, key: String = "hanna"+) -> CharacterFact {+    CharacterFact(statement: statement, quote: quote, nameKey: key, source: source)+}++@Suite("The character extraction candidate read (Q78)", .serialized)+struct CharacterExtractionCandidateReadTests {++    private func fixture() async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "the cast is Hanna and Bruce"),+                M5SeedWork(id: workB, displayTitle: "Serial B", hostname: "c.example"),+            ],+            entries: [+                M5SeedEntry(+                    id: entry1, captureTitle: "Ch 1", hostname: "c.example", path: "1",+                    note: "Hanna led the squad", firstCapturedAt: epoch,+                    lastSharedAt: epoch.addingTimeInterval(10), workID: workA),+                M5SeedEntry(+                    id: entry2, captureTitle: "Ch 2", hostname: "c.example", path: "2",+                    note: "", firstCapturedAt: epoch.addingTimeInterval(1),+                    lastSharedAt: epoch.addingTimeInterval(1), workID: workA),+            ])+        return fixture+    }++    @Test("Every source of a work comes back with its fingerprint and its coverage")+    func sourcesCarryFingerprintsAndCoverage() async throws {+        let fixture = try await fixture()+        let candidates = try await fixture.repository.characterExtractionCandidates(limit: 100)++        let candidate = try #require(candidates.first { $0.workID == workA })+        #expect(candidate.displayTitle == "Serial A")+        #expect(candidate.sources.map(\.ref) == [.genericNotes, .entry(entry1)],+                """+                generic notes first, then noted entries in capture order; an unnoted \+                entry is not a source at all+                """)+        #expect(candidate.sources[0].fingerprint+                == CharacterCoverageFingerprint.of("the cast is Hanna and Bruce"))+        #expect(candidate.sources.allSatisfy { !$0.isCovered })+        #expect(candidate.uncoveredSources.count == 2)++        // A work with no notes at all is not a candidate: there is nothing to+        // extract from it, and listing it would spend a sweep slot on nothing.+        #expect(!candidates.contains { $0.workID == workB })+        withExtendedLifetime(fixture) {}+    }++    @Test("A covered revision reads as covered, and editing the note uncovers it again")+    func coverageIsPerRevision() async throws {+        let fixture = try await fixture()+        let fingerprint = CharacterCoverageFingerprint.of("Hanna led the squad")+        let written = try await fixture.repository.advanceCharacterCoverage(+            workID: workA,+            sources: [CharacterCompletedSource(ref: .entry(entry1), fingerprint: fingerprint)])+        #expect(written == 1)++        var candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.uncoveredSources.map(\.ref) == [.genericNotes])++        try await fixture.repository.rewriteEntryNote(entry1, to: "Hanna led the squad twice")+        candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.uncoveredSources.count == 2,+                "editing the note *is* the invalidation of its old revision's coverage")+        withExtendedLifetime(fixture) {}+    }++    /// Q65: covering more is the safe direction, but a fingerprint that no+    /// longer describes the text is dropped rather than written — so coverage+    /// can never regress and can never certify a revision nobody processed.+    @Test("A stale fingerprint is dropped rather than written as coverage")+    func staleCoverageIsDropped() async throws {+        let fixture = try await fixture()+        let written = try await fixture.repository.advanceCharacterCoverage(+            workID: workA,+            sources: [CharacterCompletedSource(+                ref: .entry(entry1), fingerprint: CharacterCoverageFingerprint.of("something else"))])+        #expect(written == 0)++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.uncoveredSources.count == 2)+        withExtendedLifetime(fixture) {}+    }++    /// Q84: a generic-notes edit moves no entry timestamp, so without the work's+    /// own clock such a work would never surface.+    @Test("Recency counts the work's own modifiedAt where the generic notes are non-empty")+    func recencyIncludesTheWorksOwnClock() async throws {+        let fixture = try await fixture()+        try await fixture.repository.touchWorkModifiedAt(+            workA, to: epoch.addingTimeInterval(10_000))++        let candidates = try await fixture.repository.characterExtractionCandidates(limit: 100)+        let candidate = try #require(candidates.first { $0.workID == workA })+        #expect(candidate.recency == epoch.addingTimeInterval(10_000))+        withExtendedLifetime(fixture) {}+    }++    @Test("Candidates come back newest activity first, capped at the limit")+    func orderedByRecencyAndCapped() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Older", hostname: "c.example",+                    genericNotes: "a"),+                M5SeedWork(+                    id: workB, displayTitle: "Newer", hostname: "c.example",+                    genericNotes: "b"),+            ])+        try await fixture.repository.touchWorkModifiedAt(workB, to: epoch.addingTimeInterval(500))++        let all = try await fixture.repository.characterExtractionCandidates(limit: 100)+        #expect(all.map(\.workID) == [workB, workA])+        let capped = try await fixture.repository.characterExtractionCandidates(limit: 1)+        #expect(capped.map(\.workID) == [workB])+        withExtendedLifetime(fixture) {}+    }++    /// Q53: their proposals could not be accepted and would die undecided on a+    /// restart, so the budget is better spent elsewhere.+    @Test("A torn work is passed over")+    func tornWorkIsExcluded() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "one"),+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "two"),+            ])++        let candidates = try await fixture.repository.characterExtractionCandidates(limit: 100)+        #expect(candidates.isEmpty)+        withExtendedLifetime(fixture) {}+    }++    @Test("Accepted facts, match keys and active suppressions all come out of the one read")+    func filterInputIsComplete() async throws {+        let fixture = try await fixture()+        try await fixture.repository.seedM5Rows(+            characters: [+                M5SeedCharacter(+                    id: hanna, name: "Hanna", nameKey: "hanna", aliases: ["Action Girl"],+                    facts: [fact("Leads", "she led", .entry(entry1))],+                    workID: workA),+            ],+            suppressions: [+                M5SeedSuppression(workID: workA, kind: .candidate, nameKey: "redevelopment law"),+                M5SeedSuppression(+                    workID: workA, kind: .fact, nameKey: "hanna",+                    source: .genericNotes, evidence: "a quote"),+            ])++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.acceptedFactIdentities+                == [CharacterFactIdentity(+                    nameKey: "hanna", source: .entry(entry1), quote: "she led")])+        #expect(candidate.suppressions.candidateKeys == ["redevelopment law"])+        #expect(candidate.suppressions.factIdentities+                == [CharacterFactIdentity(+                    nameKey: "hanna", source: .genericNotes, quote: "a quote")])++        // Req 2.3's tiers, over the read's own match targets.+        #expect(candidate.match(nameKey: "hanna")?.id == hanna)+        #expect(candidate.match(nameKey: "action girl")?.id == hanna)+        #expect(candidate.match(nameKey: "bruce") == nil)+        withExtendedLifetime(fixture) {}+    }++    /// Q82: a clear must not be undone by an older suppression syncing in.+    @Test("Suppression convergence is by reader-action recency, cleared-wins on a tie")+    func suppressionConvergence() async throws {+        let fixture = try await fixture()+        let older = UUID(uuidString: "0F000000-0000-4000-8000-000000000301")!+        let newer = UUID(uuidString: "0F000000-0000-4000-8000-000000000302")!+        let tieA = UUID(uuidString: "0F000000-0000-4000-8000-000000000303")!+        let tieB = UUID(uuidString: "0F000000-0000-4000-8000-000000000304")!+        try await fixture.repository.seedM5Rows(suppressions: [+            // One key, two rows: an old suppression and a newer clear.+            M5SeedSuppression(+                id: older, workID: workA, nameKey: "ghost", status: .active, actionAt: epoch),+            M5SeedSuppression(+                id: newer, workID: workA, nameKey: "ghost", status: .cleared,+                actionAt: epoch.addingTimeInterval(60)),+            // One key, two rows with the same instant: cleared wins.+            M5SeedSuppression(+                id: tieA, workID: workA, nameKey: "wraith", status: .active, actionAt: epoch),+            M5SeedSuppression(+                id: tieB, workID: workA, nameKey: "wraith", status: .cleared, actionAt: epoch),+        ])++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.suppressions.candidateKeys.isEmpty,+                "the clear is the reader's most recent action on both keys")+        withExtendedLifetime(fixture) {}+    }++    /// Req 6.7: an orphan is inert. It must not be read as one of the work's+    /// characters, and it must not take the read down.+    @Test("A sync-orphaned character does not join any work's match targets")+    func orphanIsNotAMatchTarget() async throws {+        let fixture = try await fixture()+        try await fixture.repository.seedM5Rows(characters: [+            M5SeedCharacter(id: hanna, name: "Hanna", workID: nil),+        ])++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100)+                .first { $0.workID == workA })+        #expect(candidate.characters.isEmpty)+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Committing a character decision (Req 2.2, 2.7, 2.8)", .serialized)+struct CharacterDecisionCommitTests {++    private func fixture(+        characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []+    ) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [M5SeedWork(+                id: workA, displayTitle: "Serial A", hostname: "c.example",+                genericNotes: "the cast is Hanna")],+            entries: [M5SeedEntry(+                id: entry1, captureTitle: "Ch 1", hostname: "c.example", path: "1",+                note: "Hanna led the squad", workID: workA)],+            characters: characters,+            suppressions: suppressions)+        return fixture+    }++    private var entryFingerprint: String {+        CharacterCoverageFingerprint.of("Hanna led the squad")+    }++    private func acceptNewHanna() -> CharacterDecisionRequest {+        CharacterDecisionRequest(+            workID: workA,+            action: .accept,+            displayedKeys: ["hanna"],+            displayedTargetID: nil,+            proposedName: "Hanna",+            facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))],+            completedSources: [CharacterCompletedSource(+                ref: .entry(entry1), fingerprint: entryFingerprint)])+    }++    @Test("Accepting a candidate creates the character, covers the source and clears its key")+    func acceptCandidate() async throws {+        let fixture = try await fixture(suppressions: [+            M5SeedSuppression(workID: workA, nameKey: "hanna", status: .active, actionAt: epoch),+        ])++        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        guard case .committed(let id?) = outcome else {+            Issue.record("expected a committed character, got \(outcome)")+            return+        }++        let rows = try await fixture.repository.m5CharacterRows(id: id)+        #expect(rows.count == 1)+        #expect(rows.first?.name == "Hanna")+        #expect(rows.first?.facts.map(\.statement) == ["Leads the squad"])+        #expect(rows.first?.facts.first?.nameKey == "hanna", "keyed to the retained key (Q79)")++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.uncoveredSources.map(\.ref) == [.genericNotes],+                "the decision's own source covers in the same save (Q65)")+        #expect(candidate.suppressions.candidateKeys.isEmpty, "Req 2.5: acceptance clears the key")+        withExtendedLifetime(fixture) {}+    }++    @Test("Accepting a bundle appends to the existing character and installs unstruck aliases")+    func acceptBundle() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna",+                facts: [fact("Wears red", "her red coat", .genericNotes)], workID: workA),+        ])++        var request = acceptNewHanna()+        request.displayedTargetID = hanna+        request.proposedAliases = ["Action Girl"]+        request.displayedKeys = ["hanna", "action girl"]++        let outcome = try await fixture.repository.commitCharacterDecision(request)+        #expect(outcome == .committed(characterID: hanna))++        let rows = try await fixture.repository.m5CharacterRows(id: hanna)+        #expect(rows.first?.facts.count == 2)+        #expect(rows.first?.aliases == ["Action Girl"])+        withExtendedLifetime(fixture) {}+    }++    /// Q79: an alias spelling of an already-accepted quote dedups instead of+    /// re-proposing — the Terawatt/Terrawatt case.+    @Test("A fact matching an accepted identity is not appended twice")+    func acceptedFactsDedup() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna",+                facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))],+                workID: workA),+        ])++        var request = acceptNewHanna()+        request.displayedTargetID = hanna+        // The same quote and source, proposed under an alias spelling: re-keyed+        // to the retained key it is the same identity triple.+        request.facts = [+            fact("Leads the squad", "Hanna led the squad", .entry(entry1), key: "action girl"),+        ]++        _ = try await fixture.repository.commitCharacterDecision(request)+        let rows = try await fixture.repository.m5CharacterRows(id: hanna)+        #expect(rows.first?.facts.count == 1)+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.7: the whole point of the fingerprint. The note moved under the+    /// held proposal, so accepting it would write a quote the source no longer+    /// contains.+    @Test("A cited revision that changed refuses the acceptance and writes nothing")+    func staleSourceRefusesAcceptance() async throws {+        let fixture = try await fixture()+        try await fixture.repository.rewriteEntryNote(entry1, to: "somebody else led the squad")++        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        #expect(outcome == .refused(.staleSource(.entry(entry1))))+        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.characters.isEmpty, "a refused acceptance writes nothing")+        #expect(candidate.uncoveredSources.count == 2, "and covers nothing")+        withExtendedLifetime(fixture) {}+    }++    /// Q66: facts must never commit to a character the reader was not shown.+    @Test("A candidate displayed as new that now resolves onto a character refuses as re-routed")+    func newCandidateOntoExistingRefuses() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),+        ])++        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        #expect(outcome == .refused(.reRouted(to: hanna)))+        let rows = try await fixture.repository.m5CharacterRows(id: hanna)+        #expect(rows.first?.facts.isEmpty == true)+        withExtendedLifetime(fixture) {}+    }++    @Test("A bundle whose target no longer matches refuses as re-routed")+    func bundleTargetMovedRefuses() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(id: bruce, name: "Bruce", nameKey: "bruce", workID: workA),+        ])++        var request = acceptNewHanna()+        request.displayedTargetID = bruce++        let outcome = try await fixture.repository.commitCharacterDecision(request)+        #expect(outcome == .refused(.reRouted(to: nil)),+                "\"Hanna\" resolves onto nothing now, and certainly not onto Bruce")+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn character refuses acceptance, naming itself")+    func tornCharacterRefusesAcceptance() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna", note: "brave", workID: workA),+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna", note: "reckless", workID: workA),+        ])++        var request = acceptNewHanna()+        request.displayedTargetID = hanna++        let outcome = try await fixture.repository.commitCharacterDecision(request)+        #expect(outcome == .refused(.torn(characterID: hanna)))+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn work refuses acceptance")+    func tornWorkRefusesAcceptance() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "c.example")],+            works: [+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "one"),+                M5SeedWork(+                    id: workA, displayTitle: "Serial A", hostname: "c.example",+                    genericNotes: "two"),+            ])++        let outcome = try await fixture.repository.commitCharacterDecision(+            CharacterDecisionRequest(+                workID: workA, action: .accept, displayedKeys: ["hanna"],+                proposedName: "Hanna"))+        #expect(outcome == .refused(.torn(characterID: nil)))+        withExtendedLifetime(fixture) {}+    }++    /// Q48: a skip writes only system records, which never tear and never block+    /// anything — so the torn and staleness gates must not reach it, or Req+    /// 2.7's stale-skip rule is stranded.+    @Test("Skipping is refused by nothing: it still records under staleness and under a tear")+    func skipIsNeverRefused() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna", note: "brave", workID: workA),+            M5SeedCharacter(+                id: hanna, name: "Hanna", nameKey: "hanna", note: "reckless", workID: workA),+        ])+        try await fixture.repository.rewriteEntryNote(entry1, to: "moved on")++        let outcome = try await fixture.repository.commitCharacterDecision(+            CharacterDecisionRequest(+                workID: workA, action: .skip, displayedKeys: ["ghost"],+                proposedName: "Ghost",+                completedSources: [CharacterCompletedSource(+                    ref: .entry(entry1), fingerprint: entryFingerprint)]))+        #expect(outcome == .committed(characterID: nil))++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.suppressions.candidateKeys == ["ghost"])+        withExtendedLifetime(fixture) {}+    }++    /// Q92: a struck alias's key is not among the keys the row displayed, so the+    /// skip must not suppress it.+    @Test("Skipping a candidate suppresses exactly the keys its row displayed")+    func skipSuppressesDisplayedKeysOnly() async throws {+        let fixture = try await fixture()++        _ = try await fixture.repository.commitCharacterDecision(+            CharacterDecisionRequest(+                workID: workA, action: .skip,+                displayedKeys: ["hanna"],+                proposedName: "Hanna/Action Girl",+                proposedAliases: [],+                // The sheet sends the row's facts on every skip; a *candidate*+                // skip is a decision about the name, not about them.+                facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))]))++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.suppressions.candidateKeys == ["hanna"],+                "the struck alias half was not displayed, so it is not suppressed")+        #expect(candidate.suppressions.factIdentities.isEmpty,+                """+                Req 2.2: a candidate skip suppresses its displayed keys and \+                nothing else. Suppressing the triples too would freeze those \+                facts out of a character later created under that name, where \+                the key suppression no longer applies (Q47)+                """)+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.3, Q51, Q67 — commit-side, where `resolvedTarget` re-runs the+    /// matching the sweep's read already ran. Two answers to one question is+    /// two devices attaching one proposal's facts to two characters.+    @Test("A proposal resolves by tier first and by lowest UUID within a tier")+    func matchingPrecedenceAndTieBreak() async throws {+        // Deliberately ordered against the answer: the retained-key match holds+        // the *lowest* UUID of the three, so a tie-break applied before the+        // tiers would pick it.+        let byRetainedKey = UUID(uuidString: "0F000000-0000-4000-8000-000000000401")!+        let byCurrentName = UUID(uuidString: "0F000000-0000-4000-8000-000000000402")!+        let alsoByCurrentName = UUID(uuidString: "0F000000-0000-4000-8000-000000000403")!+        let fixture = try await fixture(characters: [+            M5SeedCharacter(+                id: byRetainedKey, name: "Alex", nameKey: "hanna", workID: workA),+            M5SeedCharacter(+                id: byCurrentName, name: "Hanna", nameKey: "terawatt", workID: workA),+            M5SeedCharacter(+                id: alsoByCurrentName, name: "Hanna", nameKey: "bruce", workID: workA),+        ])++        // Shown against the current-name match with the lowest UUID: committed.+        var request = acceptNewHanna()+        request.displayedTargetID = byCurrentName+        #expect(try await fixture.repository.commitCharacterDecision(request)+                == .committed(characterID: byCurrentName))++        // Shown against the retained-key match: the tier above it wins, so the+        // acceptance is refused and told where the row really belongs.+        request.displayedTargetID = byRetainedKey+        #expect(try await fixture.repository.commitCharacterDecision(request)+                == .refused(.reRouted(to: byCurrentName)),+                "tier order first: a current-name match beats a retained-key one")++        // And the loser of the tie-break got nothing either.+        let rows = try await fixture.repository.m5CharacterRows(id: alsoByCurrentName)+        #expect(rows.first?.facts.isEmpty == true,+                "lowest UUID settles a tier, so the other current-name match is not the target")+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.5's second limb, which nothing exercised: acceptance clears the+    /// suppression of every fact it accepted, not only of the keys the row+    /// displayed. Without it the delete-then-re-accept path (Q49) would leave a+    /// fact suppressed the reader has just said they want.+    @Test("Accepting a fact clears a standing suppression of its identity triple")+    func acceptanceClearsFactSuppression() async throws {+        let fixture = try await fixture(suppressions: [+            M5SeedSuppression(+                workID: workA, kind: .fact, nameKey: "hanna",+                source: .entry(entry1), evidence: "Hanna led the squad",+                status: .active, actionAt: epoch),+        ])++        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        guard case .committed = outcome else {+            Issue.record("expected a committed character, got \(outcome)")+            return+        }++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.suppressions.factIdentities.isEmpty,+                "Req 2.5: accepting the fact is the reader's most recent action on that triple")+        withExtendedLifetime(fixture) {}+    }++    /// Q47/Req 2.4: a name-key suppression blocks new candidates only, so+    /// writing one for a bundle would freeze an existing character out of+    /// enrichment for ever.+    @Test("Skipping a bundle suppresses the facts and never the character's name key")+    func skipBundleSuppressesFactsOnly() async throws {+        let fixture = try await fixture(characters: [+            M5SeedCharacter(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),+        ])++        _ = try await fixture.repository.commitCharacterDecision(+            CharacterDecisionRequest(+                workID: workA, action: .skip,+                displayedKeys: ["hanna"],+                displayedTargetID: hanna,+                proposedName: "Hanna",+                facts: [fact("Leads", "Hanna led the squad", .entry(entry1))]))++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.suppressions.candidateKeys.isEmpty)+        #expect(candidate.suppressions.factIdentities+                == [CharacterFactIdentity(+                    nameKey: "hanna", source: .entry(entry1), quote: "Hanna led the squad")])+        withExtendedLifetime(fixture) {}+    }++    @Test("Unticking a fact inside an accepted candidate suppresses that fact's triple")+    func untickSuppressesTheFact() async throws {+        let fixture = try await fixture()++        var request = acceptNewHanna()+        request.untickedFacts = [+            CharacterFactIdentity(nameKey: "hanna", source: .genericNotes, quote: "the cast"),+        ]+        _ = try await fixture.repository.commitCharacterDecision(request)++        let candidate = try #require(+            try await fixture.repository.characterExtractionCandidates(limit: 100).first)+        #expect(candidate.suppressions.factIdentities+                == [CharacterFactIdentity(+                    nameKey: "hanna", source: .genericNotes, quote: "the cast")])+        withExtendedLifetime(fixture) {}+    }++    /// Q82: writes update the local row in place rather than accreting one per+    /// decision, which is what stops a library from growing a suppression row+    /// per skip per device.+    @Test("A second decision on one key updates the row in place")+    func suppressionWritesInPlace() async throws {+        let fixture = try await fixture()+        let skip = CharacterDecisionRequest(+            workID: workA, action: .skip, displayedKeys: ["ghost"], proposedName: "Ghost")++        _ = try await fixture.repository.commitCharacterDecision(skip)+        _ = try await fixture.repository.commitCharacterDecision(skip)++        let rows = try await fixture.repository.m5SuppressionRows()+        #expect(rows.count == 1)+        #expect(rows.first?.status == .active)+        withExtendedLifetime(fixture) {}+    }++    @Test("A decision on a work that has gone refuses rather than throwing")+    func missingWorkRefuses() async throws {+        let fixture = try await fixture()+        let outcome = try await fixture.repository.commitCharacterDecision(+            CharacterDecisionRequest(+                workID: workB, action: .accept, proposedName: "Hanna"))+        #expect(outcome == .refused(.workGone))+        withExtendedLifetime(fixture) {}+    }+}++// MARK: - Seeding helpers these suites need++extension LibraryRepository {+    /// Rewrites a note straight through the save strategy: the point is to move+    /// the source revision under a held proposal, which is a sync event rather+    /// than a curation edit.+    func rewriteEntryNote(_ id: UUID, to note: String) async throws {+        try await withLockedContext(mode: .exclusive, operation: "rewriting a note") { context in+            let rows = try context.fetch(+                FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }))+            for row in rows { row.note = note }+            try context.save()+        }+    }++    /// Moves a work's own clock, the event Q84's recency rule exists for.+    func touchWorkModifiedAt(_ id: UUID, to date: Date) async throws {+        try await withLockedContext(mode: .exclusive, operation: "touching a work") { context in+            let rows = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+            for row in rows { row.modifiedAt = date }+            try context.save()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift Added +231 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swiftnew file mode 100644index 0000000..a267278--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift@@ -0,0 +1,231 @@+import Foundation+import Testing++@testable import AsterismCore++/// The value layer under `Character`: the name key that decides which character a+/// proposal routes to, and the canonical fact encoding two devices must agree on+/// byte for byte or the record false-tears (Q75).+@Suite("Character name keys (Q41/Q64)")+struct CharacterNameKeyTests {++    @Test("Keying is trim, NFC, locale-free case fold — the WorkTypeName recipe")+    func followsTheWorkTypeNameRecipe() {+        #expect(CharacterNameKey.normalize("  Terawatt  ") == "terawatt")+        #expect(CharacterNameKey.normalize("TERAWATT") == "terawatt")+        // Decomposed é and precomposed é are one name: composition happens before+        // folding, so the folder sees the same scalars either way.+        #expect(CharacterNameKey.normalize("Rene\u{0301}e") == CharacterNameKey.normalize("Renée"))+    }++    /// The Turkish-I case, which is the whole reason the fold is locale-free: a+    /// device set to `tr_TR` must key `I` the way every other device does, or two+    /// devices attach the same facts to different characters.+    @Test("Keying is locale-stable: dotted I folds the same everywhere, dotless stays distinct")+    func turkishIIsStable() {+        #expect(CharacterNameKey.normalize("Ianthe") == "ianthe")+        #expect(CharacterNameKey.normalize("IANTHE") == "ianthe")+        #expect(CharacterNameKey.normalize("Ianthe") != CharacterNameKey.normalize("Ianthe")+            .replacingOccurrences(of: "i", with: "\u{0131}"),+                "the dotless ı is a different name, not a fold of I")+    }++    @Test("Keying is idempotent")+    func isIdempotent() {+        for name in ["The Crowned One", "  Bruce  ", "TERAWATT", "the the queen", "Renée"] {+            let once = CharacterNameKey.normalize(name)+            #expect(CharacterNameKey.normalize(once) == once, "re-keying \(name) moved the key")+        }+    }++    /// Q64: the leading English article comes off, after folding. The prototype+    /// had "The Crowned One" and "crowned one" as one character.+    @Test("A leading article is stripped, and only that article")+    func stripsTheLeadingArticle() {+        #expect(CharacterNameKey.normalize("The Crowned One") == "crowned one")+        #expect(CharacterNameKey.normalize("crowned one") == "crowned one")+        #expect(CharacterNameKey.normalize("Theodore") == "theodore",+                "the article needs its trailing space; a name starting \"the\" is not an article")+        #expect(CharacterNameKey.normalize("A Queen") == "a queen",+                "only \"the\" is stripped; extending the list is speculation")+        #expect(CharacterNameKey.normalize("The   Crowned One") == "crowned one",+                "the residue is re-trimmed, so spacing cannot split one character in two")+        // Stripping repeats so the key is idempotent — see the doc comment on+        // `normalize`. A key that moved on re-normalisation would break the bare+        // retained key a combine stores as an alias (Q91).+        #expect(CharacterNameKey.normalize("the the queen") == "queen")+    }++    @Test("A blank name keys to the empty string rather than to a space")+    func blankNameKeysEmpty() {+        #expect(CharacterNameKey.normalize("   ").isEmpty)+        // "the " trims to "the", which is a name and not an article: the article+        // form needs a word after it. A character genuinely named "The" keys to+        // itself rather than to nothing.+        #expect(CharacterNameKey.normalize("the ") == "the")+        #expect(CharacterNameKey.normalize("The The") == "the",+                "the trailing word is a name, not a second article to strip")+    }+}++@Suite("Canonical fact encoding (Q75/Q98)")+struct CharacterFactCodecTests {++    private static let entryA = UUID(uuidString: "AAAAAAAA-0000-4000-8000-000000000001")!+    private static let entryB = UUID(uuidString: "BBBBBBBB-0000-4000-8000-000000000002")!++    private func fact(+        _ statement: String, _ quote: String, _ source: SourceRef, key: String = "hanna"+    ) -> CharacterFact {+        CharacterFact(statement: statement, quote: quote, nameKey: key, source: source)+    }++    /// The property the whole tear story rests on: two devices holding the same+    /// facts in different arrival orders must produce identical bytes.+    @Test("Encoding is order-independent")+    func encodingIsOrderIndependent() {+        let facts = [+            fact("Leads the squad", "she led the squad", .entry(Self.entryB)),+            fact("Wears red", "her red coat", .genericNotes),+            fact("Fears heights", "would not climb", .entry(Self.entryA)),+        ]+        let forward = CharacterFactCodec.encode(facts)+        let backward = CharacterFactCodec.encode(facts.reversed())+        #expect(forward != nil)+        #expect(forward == backward)+    }++    /// Q88's display order is the canonical order: generic notes first, then+    /// entry citations by UUID, then quote, then statement.+    @Test("Canonical order is generic notes, then entry UUID, then quote, then statement")+    func canonicalOrderIsTotal() {+        let facts = [+            fact("Second", "b", .entry(Self.entryB)),+            fact("First", "a", .entry(Self.entryA)),+            fact("Zeroth", "z", .genericNotes),+        ]+        #expect(CharacterFactCodec.canonicalOrder(facts).map(\.statement)+                == ["Zeroth", "First", "Second"])+    }++    /// Q98: edited-apart copies share one identity triple, so the order would not+    /// be total without the statement component — and a non-total order means two+    /// devices can encode the same set two ways and false-tear.+    @Test("Edited-apart copies of one triple order deterministically by statement")+    func editedApartCopiesOrderTotally() {+        let one = fact("She led it", "she led the squad", .entry(Self.entryA))+        let other = fact("Leads the squad", "she led the squad", .entry(Self.entryA))+        #expect(one.identity == other.identity, "the triple is shared, per Q98")++        #expect(CharacterFactCodec.encode([one, other]) == CharacterFactCodec.encode([other, one]))+        #expect(CharacterFactCodec.canonicalOrder([one, other]).map(\.statement)+                == ["Leads the squad", "She led it"])+    }++    @Test("Encoding round-trips every field")+    func roundTrips() {+        let facts = [+            fact("Wears red", "her red coat", .genericNotes, key: "hanna"),+            fact("Fears heights", "would not climb", .entry(Self.entryA), key: "hanna"),+        ]+        let decoded = CharacterFactCodec.decode(CharacterFactCodec.encode(facts))+        #expect(decoded == CharacterFactCodec.canonicalOrder(facts))+        #expect(decoded.map(\.nameKey) == ["hanna", "hanna"])+        #expect(decoded.map(\.source) == [.genericNotes, .entry(Self.entryA)])+    }++    /// An empty list stores nil rather than `[]`: CloudKit materialises a missing+    /// column as nil, so "no facts yet" and "column not synced" must read alike.+    @Test("No facts encodes to nil, and nil decodes to no facts")+    func emptyIsNil() {+        #expect(CharacterFactCodec.encode([]) == nil)+        #expect(CharacterFactCodec.decode(nil).isEmpty)+        #expect(CharacterFactCodec.decode(Data()).isEmpty)+    }++    /// Req 6.7: a blob arriving over sync that this build cannot read must not+    /// take the record down with it.+    @Test("Undecodable bytes read as no facts rather than throwing")+    func undecodableBytesAreTolerated() {+        #expect(CharacterFactCodec.decode(Data("not json".utf8)).isEmpty)+    }++    /// The comparison seam: authored-content equality re-encodes rather than+    /// comparing stored bytes, so a row written in a different order still+    /// compares equal.+    @Test("canonicalBytes normalises a differently ordered blob")+    func canonicalBytesNormalises() {+        let facts = [+            fact("Second", "b", .entry(Self.entryB)),+            fact("First", "a", .entry(Self.entryA)),+        ]+        let encoder = JSONEncoder()+        encoder.outputFormatting = [.sortedKeys]+        let unordered = try? encoder.encode(facts)+        #expect(unordered != nil)+        #expect(CharacterFactCodec.canonicalBytes(unordered) == CharacterFactCodec.encode(facts))+    }+}++@Suite("Source references and fact identity")+struct SourceRefTests {++    private static let entry = UUID(uuidString: "CCCCCCCC-0000-4000-8000-000000000003")!++    /// Q72: the discriminator is stored, so a malformed row is distinguishable+    /// from a generic-notes citation rather than silently reading as one.+    @Test("The stored pair round-trips, and a malformed pair reads as nil")+    func storedPairRoundTrips() {+        #expect(SourceRef(kindRaw: "genericNotes", entryID: nil) == .genericNotes)+        #expect(SourceRef(kindRaw: "entry", entryID: Self.entry) == .entry(Self.entry))+        #expect(SourceRef(kindRaw: "entry", entryID: nil) == nil,+                "an entry citation with no id is malformed, not generic notes")+        #expect(SourceRef(kindRaw: nil, entryID: nil) == nil)+        #expect(SourceRef(kindRaw: "notAKind", entryID: nil) == nil)+    }++    @Test("Generic notes sort before every entry citation")+    func genericNotesSortFirst() {+        #expect(SourceRef.genericNotes.orderToken < SourceRef.entry(Self.entry).orderToken)+    }++    /// Q29: evidence alone would bleed suppression across characters sharing a+    /// sentence, and (source, evidence) alone would too.+    @Test("Identity is the triple, and the statement is not part of it")+    func identityIsTheTriple() {+        let base = CharacterFact(+            statement: "A", quote: "q", nameKey: "hanna", source: .entry(Self.entry))+        var edited = base+        edited.statement = "B"+        #expect(edited.identity == base.identity)++        #expect(base.rekeyed(to: "bruce").identity+                != base.identity, "a different character does not share a fact triple")+        #expect(base.citing(.genericNotes).identity+                != base.identity, "a different source does not share a fact triple")+    }++    /// Q74: the quote is immutable. Re-keying and re-citing preserve it, and+    /// there is no setter — this test pins the two surviving derivations.+    @Test("Re-keying and re-citing preserve the quote and the statement")+    func derivationsPreserveTheQuote() {+        let base = CharacterFact(+            statement: "Leads", quote: "she led", nameKey: "hanna", source: .genericNotes)+        #expect(base.rekeyed(to: "actiongirl").quote == "she led")+        #expect(base.rekeyed(to: "actiongirl").statement == "Leads")+        #expect(base.citing(.entry(Self.entry)).quote == "she led")+    }+}++@Suite("Coverage fingerprints (Q30/Q81)")+struct CharacterCoverageFingerprintTests {++    @Test("The fingerprint is a pure function of the text")+    func isPure() {+        #expect(CharacterCoverageFingerprint.of("a note")+                == CharacterCoverageFingerprint.of("a note"))+        #expect(CharacterCoverageFingerprint.of("a note")+                != CharacterCoverageFingerprint.of("a note "))+        #expect(CharacterCoverageFingerprint.of("").count == 64, "SHA-256, hex-encoded")+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftindex 82d8138..068921a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -348,12 +348,12 @@ private final class RuleGroupStore {         let directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismConvergedRules-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         let container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         self.init(context: ModelContext(container))         retained = container
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swiftindex a7752e6..6e0621b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift@@ -44,12 +44,12 @@ final class DuplicateStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismDuplicateReconciler-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         seed = ModelContext(container)         if let saveStrategy {
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swiftindex 59def78..9e5a516 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift@@ -516,12 +516,12 @@ private final class ScanStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismDuplicateScan-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift Modified +49 / -34
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swiftindex c04decd..6aac7ce 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift@@ -67,15 +67,16 @@ struct FrozenLibraryPathTests {     /// writer emits the version digit plus one LF     /// (`LibraryRepository+Bootstrap.swift:352`), while every reader compares     /// the *trimmed* text (`readMarkerVersion`, `:595`) — so the extension-    /// matches the version `"6"` whereas the file holds these bytes. Req 2.8+    /// matches the version `"7"` whereas the file holds these bytes. Req 2.8     /// freezes the bytes, not the reader's tolerance: rewriting the file as a-    /// bare `"6"` would be a change to persisted state even though every current+    /// bare `"7"` would be a change to persisted state even though every current     /// reader would still accept it, and this test exists to notice that.     ///     /// The *digit* advances with each marker generation — `configurable-work-types`-    /// moved it from `"5"` to `"6"` (Q26) — and the app opens every generation it-    /// has published. What is frozen is the shape and the filename beside it.-    private static let markerContents = "6\n"+    /// moved it from `"5"` to `"6"` (Q26), `character-extraction` from `"6"` to+    /// `"7"` (Q80) — and the app opens every generation it has published. What is+    /// frozen is the shape and the filename beside it.+    private static let markerContents = "7\n"      /// Everything a fresh app-role open is allowed to leave in the root, SQLite's     /// own `-wal`/`-shm` companions excluded. An extra entry here is a path@@ -278,24 +279,26 @@ struct FrozenLibraryPathTests {     /// name here — see the rule in the suite's doc comment.     @Test("No declared identifier carries a version number it does not describe")     func noIdentifierNamesAVersionItDoesNotDescribe() throws {-        /// The store schemas this package declares — V6 live, V5 frozen as the-        /// `from` version of the lightweight stage — the plan that stages them,-        /// the floor the recorded-version reading refuses below, and the marker-        /// generations the bootstrap classifies against. `markerLaggingV4` and-        /// `markerLaggingV5` name the readiness generation the library carries,-        /// which is what those digits genuinely are (Q26); the writer itself is-        /// `publishReadiness`, unversioned, because it always writes the current-        /// one.+        /// The store schemas this package declares — V7 live, V5 and V6 frozen+        /// as the `from` versions of the two lightweight stages — the plan that+        /// stages them, the floor the recorded-version reading refuses below, and+        /// the marker generations the bootstrap classifies against.+        /// `markerLaggingV4`, `markerLaggingV5` and `markerLaggingV6` name the+        /// readiness generation the library carries, which is what those digits+        /// genuinely are (Q26); the writer itself is `publishReadiness`,+        /// unversioned, because it always writes the current one.         let declaresAStoreSchemaOrMarkerGeneration: Set<String> = [-            "AsterismSchemaV5", "AsterismSchemaV6", "AsterismV6MigrationPlan",+            "AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7",+            "AsterismV7MigrationPlan",             "atOrAboveV5", "belowV5", "firstV5Major",-            "markerLaggingV4", "markerLaggingV5",+            "markerLaggingV4", "markerLaggingV5", "markerLaggingV6",         ]-        /// The archive format — 5/6, 4/4, and the 2/2 lineage the importer still-        /// names. These name a serialization version, not a store schema, and-        /// they are accurate: `configurable-work-types` Q8 mints format 5 over-        /// schema 6 for the type list, beside the frozen 4/4 the app still-        /// imports.+        /// The archive format — 6/7, 5/6, 4/4, and the 2/2 lineage the importer+        /// still names. These name a serialization version, not a store schema,+        /// and they are accurate: `configurable-work-types` Q8 mints format 5+        /// over schema 6 for the type list, `character-extraction` Q63 mints+        /// format 6 over schema 7 for the characters, and both sit beside the+        /// frozen 4/4 the app still imports.         ///         /// `BackupImportPlan` and `plan(from:)` are deliberately **absent**: a         /// plan now carries either format (Req 7.6), so the names they used to@@ -312,16 +315,23 @@ struct FrozenLibraryPathTests {             "BackupV5ExportError", "BackupV5Metadata", "BackupV5Payload",             "BackupV5ReferenceValidator", "BackupV5ShapeValidator",             "BackupV5SnapshotProviding", "BackupV5Work", "BackupV5WorkTypeRecord",-            "backupV4Snapshot", "backupV5Snapshot", "decodeV4Date", "encodeV4Date",+            "BackupV6Character", "BackupV6Codec", "BackupV6CodecError", "BackupV6Coverage",+            "BackupV6Document", "BackupV6ExportError", "BackupV6Exporter", "BackupV6Metadata",+            "BackupV6Payload", "BackupV6ReferenceValidator", "BackupV6ShapeValidator",+            "BackupV6SnapshotProviding", "BackupV6Suppression",+            "backupV4Snapshot", "backupV5Snapshot", "backupV6Snapshot",+            "decodeV4Date", "encodeV4Date",             "importedV2", "importedV2Path",             "mapV4EntryRecord", "mapV4SiteRecord", "mapV4TitlePatternRecord",             "mapV4URLRuleRecord", "mapV4WorkRecord", "mapV5WorkRecord",-            "materializeV4Payload", "materializeV5Payload",-            "planFromV4Archive", "planFromV5Archive",-            "projectV4Payload", "projectV5Payload",-            "v4Archive", "v5Archive",+            "mapV6CharacterRecord", "mapV6SuppressionRecord",+            "materializeV4Payload", "materializeV5Payload", "materializeV6Payload",+            "planFromV4Archive", "planFromV5Archive", "planFromV6Archive",+            "projectV4Payload", "projectV5Payload", "projectV6Payload", "projectV6Coverage",+            "v4Archive", "v5Archive", "v6Archive",             "validateImportPlanPayloadV4", "validateImportPlanPayloadV5",-            "validateV4", "validateV5",+            "validateImportPlanPayloadV6",+            "validateV4", "validateV5", "validateV6",         ]         /// The Entry identity-key generation, `EntryIdentityKeyV2Codec` /         /// `V3Codec`. A v2 key and a v3 key are different encodings of the same@@ -370,24 +380,29 @@ struct FrozenLibraryPathTests {                 .map { String($0.1) }         }         #expect(-            declared.sorted() == ["AsterismSchemaV5", "AsterismSchemaV6"],+            declared.sorted() == ["AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7"],             "the package declares versioned schemas \(declared); Req 3.3 allows only ones a plan references") -        let referenced = AsterismV6MigrationPlan.schemas.map { String(describing: $0) }+        let referenced = AsterismV7MigrationPlan.schemas.map { String(describing: $0) }         #expect(-            referenced == ["AsterismSchemaV5", "AsterismSchemaV6"],+            referenced == ["AsterismSchemaV5", "AsterismSchemaV6", "AsterismSchemaV7"],             "the plan references \(referenced), which is not the set of declared schemas")-        // One stage, and a lightweight one: the V5 → V6 step adds a column and a-        // table and changes nothing that exists, so `ModelContainer.init` runs-        // the whole conversion. A second stage here means a snapshot came back.+        // Two stages, both lightweight: V5 → V6 adds a column and a table, and+        // V6 → V7 adds two columns and two tables. Neither changes anything that+        // exists, so `ModelContainer.init` runs both conversions. The V5 stage+        // stays deliberately (Q80): retiring it would carry+        // `retire-migration-chain` Decision 6's population precondition.         #expect(-            AsterismV6MigrationPlan.stages.count == 1,-            "the plan stages \(AsterismV6MigrationPlan.stages.count) migrations; V5 → V6 is one")+            AsterismV7MigrationPlan.stages.count == 2,+            "the plan stages \(AsterismV7MigrationPlan.stages.count) migrations; V5 → V6 → V7 is two")         #expect(             AsterismSchemaV5.versionIdentifier == Schema.Version(5, 0, 0),             "the frozen snapshot's version stamp is the `from` side every V5 store is matched on")         #expect(             AsterismSchemaV6.versionIdentifier == Schema.Version(6, 0, 0),+            "the frozen snapshot's version stamp is the `from` side every V6 store is matched on")+        #expect(+            AsterismSchemaV7.versionIdentifier == Schema.Version(7, 0, 0),             "the live schema's version stamp is what every recorded store is compared against")     } 
Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swiftindex 641b355..7d1b70c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift@@ -221,12 +221,12 @@ private final class GroupStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismGroupFetch-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swiftindex 1310daf..a2aa594 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -531,12 +531,12 @@ private final class OrderingStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismGroupOrdering-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swiftindex 5947abe..8914bfc 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift@@ -388,12 +388,12 @@ private final class ResolutionStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])     } 
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swiftindex 545be64..f74fe1a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift@@ -338,12 +338,12 @@ private final class ToleranceScanStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])     } 
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swiftindex 5c51a15..bcd241f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift@@ -323,12 +323,12 @@ private final class ValidatorStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismValidatorTolerance-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swiftindex 1e9e823..6d659d2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift@@ -357,7 +357,7 @@ private struct LookupCaptureFixture {         _ seed: (ModelContext, String) -> Void     ) throws -> LookupCaptureFixture {         let url = "https://example.com/chapter-1"-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let container = try ModelContainer(             for: schema,             configurations: [
Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift Modified +210 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex ce9f5a3..0f6dd58 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -129,11 +129,128 @@ struct M5SeedEntry: Sendable {     } } +/// One `Character` **row**. Two rows sharing `id` are one split group; giving+/// them different authored content is the torn shape Req 6.5 is about.+///+/// `workID` is optional so a suite can seed the sync orphan of Req 6.7 — a+/// character whose work has not arrived — which no write path produces.+struct M5SeedCharacter: Sendable {+    var id: UUID+    var name: String+    var nameKey: String?+    var aliases: [String] = []+    var note: String = ""+    var facts: [CharacterFact] = []+    var workID: UUID?+    /// Which row of `workID`'s group this row points at, in seeding order.+    var workRowIndex: Int = 0+    var createdAt: Date = M5Fixture.epoch+    var modifiedAt: Date = M5Fixture.epoch++    init(+        id: UUID, name: String, nameKey: String? = nil, aliases: [String] = [],+        note: String = "", facts: [CharacterFact] = [], workID: UUID? = nil,+        workRowIndex: Int = 0, createdAt: Date = M5Fixture.epoch,+        modifiedAt: Date = M5Fixture.epoch+    ) {+        self.id = id+        self.name = name+        self.nameKey = nameKey+        self.aliases = aliases+        self.note = note+        self.facts = facts+        self.workID = workID+        self.workRowIndex = workRowIndex+        self.createdAt = createdAt+        self.modifiedAt = modifiedAt+    }+}++/// A suppression row as a value, so a suite can assert about one outside the+/// repository actor.+struct M5SuppressionSnapshot: Sendable, Equatable {+    var id: UUID+    var workID: UUID?+    var kind: CharacterSuppressionKind+    var nameKey: String+    var source: SourceRef?+    var evidence: String?+    var status: CharacterSuppressionStatus+    var actionAt: Date++    init(_ row: CharacterSuppression) {+        id = row.id+        workID = row.work?.id+        kind = row.kind+        nameKey = row.nameKey+        source = row.source+        evidence = row.evidence+        status = row.status+        actionAt = row.actionAt+    }+}++/// A character row as a value, so a suite can assert about one outside the+/// repository actor. Row-level, `work` included, which is what an archive+/// round-trip and a sync-orphan test both need to see.+struct M5CharacterSnapshot: Sendable, Equatable {+    var id: UUID+    var workID: UUID?+    var name: String+    var nameKey: String+    var aliases: [String]+    var note: String+    var facts: [CharacterFact]+    var createdAt: Date+    var modifiedAt: Date++    init(_ row: CharacterRecord) {+        id = row.id+        workID = row.work?.id+        name = row.name+        nameKey = row.nameKey+        aliases = row.aliases+        note = row.note+        facts = row.facts+        createdAt = row.createdAt+        modifiedAt = row.modifiedAt+    }+}++/// One `CharacterSuppression` row.+struct M5SeedSuppression: Sendable {+    var id: UUID+    var workID: UUID?+    var kind: CharacterSuppressionKind = .candidate+    var nameKey: String+    var source: SourceRef?+    var evidence: String?+    var status: CharacterSuppressionStatus = .active+    var actionAt: Date = M5Fixture.epoch++    init(+        id: UUID = UUID(), workID: UUID? = nil,+        kind: CharacterSuppressionKind = .candidate, nameKey: String,+        source: SourceRef? = nil, evidence: String? = nil,+        status: CharacterSuppressionStatus = .active, actionAt: Date = M5Fixture.epoch+    ) {+        self.id = id+        self.workID = workID+        self.kind = kind+        self.nameKey = nameKey+        self.source = source+        self.evidence = evidence+        self.status = status+        self.actionAt = actionAt+    }+}+ extension LibraryRepository {      /// Writes the given rows into an empty (or not) store in one save.     func seedM5Rows(-        sites: [M5SeedSite] = [], works: [M5SeedWork] = [], entries: [M5SeedEntry] = []+        sites: [M5SeedSite] = [], works: [M5SeedWork] = [], entries: [M5SeedEntry] = [],+        characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []     ) async throws {         try await withLockedContext(mode: .exclusive, operation: "seeding M5 test rows") { context in             var siteRows: [String: Site] = [:]@@ -212,9 +329,101 @@ extension LibraryRepository {                     entry.workAssignmentProvenance = seed.workAssignmentProvenance                 }             }++            func workRow(_ id: UUID?, index: Int) throws -> Work? {+                guard let id else { return nil }+                let rows = try workRows[id]+                    ?? GroupOrdering.sortedWorkRows(+                        context.fetch(+                            FetchDescriptor<Work>(predicate: #Predicate { $0.id == id })))+                guard index < rows.count else { return nil }+                return rows[index]+            }++            for seed in characters {+                let character = CharacterRecord(+                    id: seed.id,+                    name: seed.name,+                    nameKey: seed.nameKey ?? CharacterNameKey.normalize(seed.name),+                    aliases: seed.aliases,+                    note: seed.note,+                    facts: seed.facts,+                    timestamp: seed.createdAt)+                character.modifiedAt = seed.modifiedAt+                context.insert(character)+                character.work = try workRow(seed.workID, index: seed.workRowIndex)+            }++            for seed in suppressions {+                let row = CharacterSuppression(+                    id: seed.id, kind: seed.kind, nameKey: seed.nameKey,+                    source: seed.source, evidence: seed.evidence, status: seed.status,+                    actionAt: seed.actionAt)+                context.insert(row)+                row.work = try workRow(seed.workID, index: 0)+            }             try context.save()         }     }++    /// Every character row for one application UUID, in representative order, as+    /// values — `@Model` classes are not `Sendable` and may not leave the actor.+    func m5CharacterRows(id: UUID) async throws -> [CharacterAuthoredContent] {+        try await withLockedContext(mode: .shared, operation: "reading character rows") { context in+            GroupOrdering.sortedCharacterRows(+                try LibraryRepository.characterRows(ids: [id], context: context)[id] ?? []+            ).map(GroupOrdering.authoredContent(of:))+        }+    }++    /// The suppression rows in the store, as values, in a stable order.+    func m5SuppressionRows() async throws -> [M5SuppressionSnapshot] {+        try await withLockedContext(mode: .shared, operation: "reading suppressions") { context in+            try context.fetch(FetchDescriptor<CharacterSuppression>())+                .map(M5SuppressionSnapshot.init)+                .sorted { $0.id.uuidString < $1.id.uuidString }+        }+    }++    /// Every character row in the store, as values, in a stable order.+    func m5AllCharacters() async throws -> [M5CharacterSnapshot] {+        try await withLockedContext(mode: .shared, operation: "reading characters") { context in+            try context.fetch(FetchDescriptor<CharacterRecord>())+                .map(M5CharacterSnapshot.init)+                .sorted { $0.id.uuidString < $1.id.uuidString }+        }+    }++    /// The coverage fingerprint one Entry carries, or nil where no pass has+    /// covered its note. Nil too where the rows of a split group disagree —+    /// coverage is a system record written to every row, so a disagreement is+    /// the interesting answer rather than one row's.+    func m5EntryCoverage(_ id: UUID) async throws -> String? {+        try await withLockedContext(mode: .shared, operation: "reading entry coverage") {+            context in+            let values = Set(+                try context.fetch(FetchDescriptor<Entry>(predicate: #Predicate { $0.id == id }))+                    .map { $0.characterExtractionFingerprint })+            return values.count == 1 ? values.first ?? nil : nil+        }+    }++    /// The same, for a Work's generic notes.+    func m5WorkCoverage(_ id: UUID) async throws -> String? {+        try await withLockedContext(mode: .shared, operation: "reading work coverage") { context in+            let values = Set(+                try context.fetch(FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+                    .map { $0.genericNotesExtractionFingerprint })+            return values.count == 1 ? values.first ?? nil : nil+        }+    }++    /// The duplicate scan over the seeded store.+    func m5Scan() async throws -> DuplicateScanResult {+        try await withLockedContext(mode: .shared, operation: "scanning duplicates") { context in+            try DuplicateScan.run(context: context)+        }+    } }  extension LibraryRepository {
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift Modified +23 / -21
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftindex 29accc1..a0ef175 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -41,7 +41,7 @@ struct MarkerContractTests {      /// A first run: creates an empty store and marks it ready at birth. An     /// empty store has nothing to migrate, so mark-at-birth certifies it at-    /// `"6"` directly (Q26).+    /// `"7"` directly (Q26).     private func makeReadyLibrary(_ configuration: LibraryConfiguration) async throws {         _ = try await LibraryRepository.openForApp(configuration)     }@@ -72,30 +72,31 @@ struct MarkerContractTests {      // MARK: - App side accepts every lagging generation -    @Test("The app opens a library marked \"4\", one marked \"5\" and one marked \"6\"")+    @Test("The app opens libraries marked \"4\", \"5\", \"6\" and \"7\"")     func appAcceptsEveryOpenableMarkerVersion() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        #expect(try markerContent(cfg) == "6",+        #expect(try markerContent(cfg) == "7",                 "an empty store has nothing to migrate, so it is certified migrated (Q26)") -        let (atSix, _) = try await LibraryRepository.openForApp(cfg)-        #expect(atSix == .ready(.seededEmpty), "a certified library opens in the app")+        let (current, _) = try await LibraryRepository.openForApp(cfg)+        #expect(current == .ready(.seededEmpty), "a certified library opens in the app")          // Deliberate pre-migration states: the markers a pre-freeze build's-        // library and a pre-`configurable-work-types` build's library still-        // carry. The app opens both and republishes "6" (Q31, Q26).-        for lagging in ["4\n", "5\n"] {+        // library, a pre-`configurable-work-types` build's and a+        // pre-`character-extraction` build's still carry. The app opens all+        // three and republishes "7" (Q31, Q26, Q80).+        for lagging in ["4\n", "5\n", "6\n"] {             try writeMarker(cfg, lagging)             let (result, _) = try await LibraryRepository.openForApp(cfg)             #expect(result == .ready(.seededEmpty),                     "the upgrade path exists for libraries still marked \(lagging.debugDescription)")-            #expect(try markerContent(cfg) == "6", "the open republishes readiness at \"6\"")+            #expect(try markerContent(cfg) == "7", "the open republishes readiness at \"7\"")         }     }      @Test("The app fails closed on a marker version it does not open",-          arguments: ["3\n", "7\n", "45\n", "", "four\n"])+          arguments: ["3\n", "8\n", "45\n", "", "four\n"])     func appRejectsUnknownMarkerVersions(content: String) async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)@@ -108,11 +109,11 @@ struct MarkerContractTests {      // MARK: - Extension side requires the current version -    @Test("The extension opens a library marked \"6\"")+    @Test("The extension opens a library marked \"7\"")     func extensionAcceptsTheCurrentVersion() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        #expect(try markerContent(cfg) == "6")+        #expect(try markerContent(cfg) == "7")          let (result, _) = try await LibraryRepository.openForExtension(cfg)         #expect(result == .ready(.seededEmpty))@@ -132,23 +133,24 @@ struct MarkerContractTests {     /// first launched the library still records `"5"`, and a capture in that     /// window must fail safely with the shipped message rather than convert the     /// store under a shared lock.-    @Test("The extension declines a library marked \"5\", the update window, with the shipped message")-    func extensionDeclinesTheUpdateWindow() async throws {+    @Test("The extension declines a lagging marker, the update window, with the shipped message",+          arguments: ["5", "6"])+    func extensionDeclinesTheUpdateWindow(lagging: String) async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        try writeMarker(cfg, "5\n")+        try writeMarker(cfg, "\(lagging)\n")          await #expect(throws: Self.declined) {             try await LibraryRepository.openForExtension(cfg)         }-        #expect(try markerContent(cfg) == "5", "the extension may not republish readiness")+        #expect(try markerContent(cfg) == lagging, "the extension may not republish readiness")     }      @Test("The extension declines a \"4\" marker before it constructs a ModelContainer")     func extensionDeclinesBeforeOpeningAContainer() async throws {         let (_, cfg) = try config()         // A genuinely 5.0.0-recorded store, not a corrupt one: the container-        // *would* open it, converting it to 6.0.0 in a process holding only a+        // *would* open it, converting it to 7.0.0 in a process holding only a         // shared lock. That is the hazard (Q14) — a store that cannot be opened         // at all would prove nothing about the ordering, which is why this uses         // the frozen-snapshot seed rather than the 4.0.0 fixture the declared@@ -162,12 +164,12 @@ struct MarkerContractTests {         #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["5.0.0"],                 "the marker check must decide before ModelContainer.init converts anything") -        // Control: with a "6" marker the same store is reached, opened, and+        // Control: with a "7" marker the same store is reached, opened, and         // converted. Without this the assertion above could hold because the         // store was unopenable rather than because the marker was read first.-        try writeMarker(cfg, "6\n")+        try writeMarker(cfg, "7\n")         _ = try await LibraryRepository.openForExtension(cfg)-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["6.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["7.0.0"],                 "the same store converts once the marker check passes")     } @@ -175,7 +177,7 @@ struct MarkerContractTests {     func extensionRejectsUnknownMarkerVersions() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        try writeMarker(cfg, "7\n")+        try writeMarker(cfg, "8\n")          await #expect(throws: LibraryRepositoryError.self) {             try await LibraryRepository.openForExtension(cfg)
Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swiftindex f802703..9e12e98 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift@@ -136,7 +136,7 @@ struct MirroringBootstrapLifecycleTests {         // The whole point of the two-phase open: by the time the mirrored         // container is constructed, the store is marked "6" — so CloudKit cannot         // fill an unmarked store (Req 6.1, Q22, Q35).-        #expect(call.markerVersion == "6")+        #expect(call.markerVersion == "7")         #expect(call.storeExists)         #expect(call.containerID == Self.fixtureContainer)         #expect(call.storeURL == configuration.storeURL)@@ -159,7 +159,7 @@ struct MirroringBootstrapLifecycleTests {             mirroring: recordingHooks(configuration, log: log, bootstrapBox: bootstrapBox))          #expect(log.callCount == 1)-        #expect(log.calls.first?.markerVersion == "6")+        #expect(log.calls.first?.markerVersion == "7")         #expect(await repository.mirroring.isMirroring)         // Q35/Q43 on this path too. The already-certified branch opens its own         // certification container to run the validator over an existing marker,@@ -186,7 +186,7 @@ struct MirroringBootstrapLifecycleTests {          #expect(result == .ready(LibraryRecordCounts(             entries: 0, works: 0, sites: 1, titlePatterns: 0).withSeededWorkTypes))-        #expect(log.calls.first?.markerVersion == "6")+        #expect(log.calls.first?.markerVersion == "7")         #expect(await repository.mirroring.isMirroring)         withExtendedLifetime(dir) {}     }@@ -219,7 +219,7 @@ struct MirroringBootstrapLifecycleTests {         #expect(result == .ready(LibraryRecordCounts(             entries: 0, works: 0, sites: 1, titlePatterns: 0).withSeededWorkTypes))         #expect(log.callCount == 1)-        #expect(log.calls.first?.markerVersion == "6",+        #expect(log.calls.first?.markerVersion == "7",                 "the mirror may not attach to a library the pass has not certified")         #expect(await repository.mirroring.isMirroring)         #expect(bootstrapBox.value == nil, "the certification container outlived certification")
Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift Modified +41 / -6
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex acef54d..ba9e80c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -132,12 +132,19 @@ struct ModelContractTests {         #expect(entry.intentionallyUnattached == false)     } -    /// The entity list is the store's shape. V6 adds the sixth entity and keeps-    /// the five V5 already had, in the same order — the frozen snapshot is the-    /// `from` side of the lightweight stage, so a divergence here is a store-    /// that will not open, not a test that needs updating.-    @Test("V6 declares six entities and V5 stays frozen at five")+    /// The entity list is the store's shape. V7 adds the seventh and eighth+    /// entities and keeps the six V6 already had, in the same order — the frozen+    /// snapshots are the `from` sides of the two lightweight stages, so a+    /// divergence here is a store that will not open, not a test that needs+    /// updating.+    @Test("V7 declares eight entities, V6 stays frozen at six and V5 at five")     func schemaEntityLists() {+        #expect(AsterismSchemaV7.versionIdentifier == Schema.Version(7, 0, 0))+        #expect(+            AsterismSchemaV7.models.map { String(describing: $0) } == [+                "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",+                "Character", "CharacterSuppression",+            ])         #expect(AsterismSchemaV6.versionIdentifier == Schema.Version(6, 0, 0))         #expect(             AsterismSchemaV6.models.map { String(describing: $0) } == [@@ -150,6 +157,34 @@ struct ModelContractTests {             ])     } +    /// V7's additions, as CloudKit will materialise them: every property+    /// defaulted or optional, nothing unique, and the fact blob nil rather than+    /// empty so "no facts yet" and "column not synced" read alike (Req 6.3).+    @Test("Character and CharacterSuppression defaults are CloudKit-legal")+    func characterDefaults() {+        let epoch = Date(timeIntervalSince1970: 0)+        let character = CharacterRecord()+        #expect(character.name.isEmpty)+        #expect(character.nameKey.isEmpty)+        #expect(character.aliases.isEmpty)+        #expect(character.note.isEmpty)+        #expect(character.factsData == nil)+        #expect(character.facts.isEmpty)+        #expect(character.createdAt == epoch)+        #expect(character.modifiedAt == epoch)+        #expect(character.work == nil)++        let suppression = CharacterSuppression()+        #expect(suppression.kind == .candidate)+        #expect(suppression.status == .active)+        #expect(suppression.nameKey.isEmpty)+        #expect(suppression.source == nil)+        #expect(suppression.sourceEntryID == nil)+        #expect(suppression.evidence == nil)+        #expect(suppression.actionAt == epoch)+        #expect(suppression.work == nil)+    }+     /// `WorkTypeEntity`'s stored defaults, per-field timestamps included. Every     /// one of them is CloudKit's answer for a record that arrives without the     /// field, and the epoch timestamps are the *pristine* sentinel convergence@@ -284,7 +319,7 @@ private struct ModelFixture {     /// The live schema, in memory. It was `AsterismSchemaV2` — a schema no     /// library was written by, which is exactly the divergence Req 4.1 is about.     init() throws {-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             schema: schema,             isStoredInMemoryOnly: true,
Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swiftindex 75ff9b2..7116809 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift@@ -382,12 +382,12 @@ private final class ReconcilerStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismSiteReconciler-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)         self.saveStrategy = saveStrategy ?? saveRecorder
Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swiftindex 2b1ec75..c5412c4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteRelationshipPopulationPassTests.swift@@ -627,7 +627,7 @@ struct SiteRelationshipPopulationPassTests {                 "the seed must be written by the frozen snapshot, not the live classes")          try runPass(at: cfg.storeURL)-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["6.0.0"])+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["7.0.0"])          let container = try LibraryRepository.openContainer(at: cfg.storeURL)         let context = ModelContext(container)@@ -748,7 +748,7 @@ struct SiteRelationshipPopulationPassTests {             _ = ModelContext(container)             withExtendedLifetime(container) {}         }-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["6.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["7.0.0"],                 "the conversion is committed before the pass runs")          let container = try LibraryRepository.openContainer(at: cfg.storeURL)
Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swiftindex 39be97e..80dae5a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -322,12 +322,12 @@ private final class ProjectionStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismSiteUnion-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV6MigrationPlan.self,+            for: schema, migrationPlan: AsterismV7MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swiftindex 4777823..0cf819c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift@@ -197,7 +197,7 @@ struct StoreMetadataTests {         try #require(FileManager.default.fileExists(atPath: dir.storeURL.path + "-wal"),                      "the conversion has to be in the log for this to be the hazard") -        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["6.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["7.0.0"],                 "the conversion is committed in the log, so a reader of the log sees it")         #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5,                 "a reader that ignored the log would still have to answer, not refuse")
Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftindex 22617d6..a5d81e1 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -944,7 +944,7 @@ struct URLOptionalSequenceArchiveTests {     #expect(decoded.payload == payload)     #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional) -    let schema = Schema(versionedSchema: AsterismSchemaV6.self)+    let schema = Schema(versionedSchema: AsterismSchemaV7.self)     let container = try ModelContainer(       for: schema,       configurations: [
Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift Modified +14 / -14
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swiftindex 1f9282a..902710f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5CertificationPathTests.swift@@ -11,7 +11,7 @@ import Testing /// sidecar resume — reached the marker through the migration machinery retired by /// task 11, and went with it. What remains is the marker-lagging path, which is /// the only surviving path that runs the pass (Decision 4), and the-/// mark-at-birth path, which publishes `"6"` directly and runs no pass (Q26) —+/// mark-at-birth path, which publishes `"7"` directly and runs no pass (Q26) — /// pinned here so it does not grow one. /// /// The marker-lagging cases are seeded from `V4RecordedStoreFixture`, the one@@ -46,7 +46,7 @@ struct V5CertificationPathTests {     }      /// Every Entry and Work in the store points at the Site row carrying its-    /// hostname — the state certification must produce before it may say "6".+    /// hostname — the state certification must produce before it may say "7".     private func expectRelationshipsPopulated(         _ configuration: LibraryConfiguration, sourceLocation: SourceLocation = #_sourceLocation     ) throws {@@ -70,7 +70,7 @@ struct V5CertificationPathTests {     }      /// The marker-lagging premise: a store recorded at 5.0.0 with every-    /// relationship still nil, which the app-role open converts to 6.0.0 on its+    /// relationship still nil, which the app-role open converts to 7.0.0 on its     /// way in.     ///     /// It was the 4.0.0 fixture, installed and then converted in a separate@@ -98,7 +98,7 @@ struct V5CertificationPathTests {      // MARK: - V4-marker path -    @Test("V4-marker: a pre-freeze library marked \"4\" opens with relationships populated and is republished at \"6\"")+    @Test("V4-marker: a pre-freeze library marked \"4\" opens with relationships populated and is republished at \"7\"")     func v4MarkerPathRunsThePass() async throws {         let (dir, cfg) = try config()         try installStoreArrivedAtV5(cfg)@@ -110,13 +110,13 @@ struct V5CertificationPathTests {             return         } -        #expect(try markerContent(cfg) == "6",+        #expect(try markerContent(cfg) == "7",                 "the app republishes readiness at \"6\" after the pass (Q14, Q26)")         try expectRelationshipsPopulated(cfg)         withExtendedLifetime(dir) {}     } -    @Test("Interrupted mid-pass: a store already at 5.0.0 with the marker still \"4\" converges and publishes \"6\" only afterwards")+    @Test("Interrupted mid-pass: a store already at 5.0.0 with the marker still \"4\" converges and publishes \"7\" only afterwards")     func interruptedStateConvergesAndRepublishes() async throws {         let (dir, cfg) = try config()         try V5RecordedStoreFixture.install(at: cfg.storeURL)@@ -132,7 +132,7 @@ struct V5CertificationPathTests {             _ = ModelContext(container)             withExtendedLifetime(container) {}         }-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["6.0.0"])+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["7.0.0"])         #expect(try markerContent(cfg) == "4",                 "the interrupted attempt must not have certified partway (Req 2.4)") @@ -141,7 +141,7 @@ struct V5CertificationPathTests {             Issue.record("expected the re-run to converge, got \(result)")             return         }-        #expect(try markerContent(cfg) == "6", "\"6\" is published only after the pass converges")+        #expect(try markerContent(cfg) == "7", "\"7\" is published only after the pass converges")         try expectRelationshipsPopulated(cfg)         withExtendedLifetime(dir) {}     }@@ -150,15 +150,15 @@ struct V5CertificationPathTests {     // Both drove the retired migration machinery — an M3-era store built through     // `openV3Container`, and a resume from a written sidecar — so neither state     // is reachable and neither test is constructible (Req 1.1, 1.2). The property-    // they shared with the surviving cases, that `"6"` is published only once the+    // they shared with the surviving cases, that `"7"` is published only once the     // relationships are populated, is asserted by the two marker-lagging cases     // above and by `failedPassDoesNotPublishTheMarker` below.      // MARK: - Already-populated and mark-at-birth paths -    @Test("An ordinary open of a library already marked \"6\" does not re-run the pass")+    @Test("An ordinary open of a library already marked \"7\" does not re-run the pass")     func ordinaryOpenDoesNotReRunThePass() async throws {-        // The pass runs once, at certification (Q29, Q31): a "6" marker means+        // The pass runs once, at certification (Q29, Q31): a "7" marker means         // it already ran, and the V4-marker branch must not sweep every Entry         // and Work on every app launch. This pins that cost, nothing more.         //@@ -172,7 +172,7 @@ struct V5CertificationPathTests {         try installStoreArrivedAtV5(cfg)         try Data("4\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)         _ = try await LibraryRepository.openForApp(cfg)-        #expect(try markerContent(cfg) == "6")+        #expect(try markerContent(cfg) == "7")         try stripRelationships(cfg)          let (result, _) = try await LibraryRepository.openForApp(cfg)@@ -280,12 +280,12 @@ struct V5CertificationPathTests {         withExtendedLifetime(dir) {}     } -    @Test("Mark-at-birth still publishes \"6\" directly for an empty store and runs no pass")+    @Test("Mark-at-birth still publishes \"7\" directly for an empty store and runs no pass")     func markAtBirthStillPublishesTheCurrentVersionDirectly() async throws {         let (dir, cfg) = try config()         let (result, _) = try await LibraryRepository.openForApp(cfg)         #expect(result == .ready(.zero))-        #expect(try markerContent(cfg) == "6",+        #expect(try markerContent(cfg) == "7",                 "an empty store has nothing to migrate and is certified migrated at birth (Q26)")         withExtendedLifetime(dir) {}     }
Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift Modified +15 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swiftindex 9210845..7c26c8f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V5RecordedStoreTests.swift@@ -4,19 +4,19 @@ import Testing  @testable import AsterismCore -/// The V5 → V6 conversion, over a store genuinely **recorded at 5.0.0**.+/// The V5 → V6 → V7 conversion, over a store genuinely **recorded at 5.0.0**. /// /// This is the path every installed library takes on the update that ships /// `configurable-work-types`: the store on disk was written by the V5 classes, /// and `ModelContainer.init` runs the plan's lightweight stage on the way in. /// Nothing else in the suite crosses that boundary — every other store a test-/// builds is born at 6.0.0 — so a regression here would otherwise only be+/// builds is born at 7.0.0 — so a regression here would otherwise only be /// visible on the owner's phone. /// /// It is the successor to `V4RecordedStoreTests`' conversion coverage, which the /// declared stage made unreachable: a 4.0.0-recorded store is now refused rather /// than raised implicitly.-@Suite("A 5.0.0-recorded store under the V6 plan", .serialized)+@Suite("A 5.0.0-recorded store under the V7 plan", .serialized) struct V5RecordedStoreTests {      private final class TempDir {@@ -40,7 +40,7 @@ struct V5RecordedStoreTests {         #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["5.0.0"])     } -    @Test("openContainer converts it to 6.0.0 and reads every field back intact")+    @Test("openContainer converts it to 7.0.0 and reads every field back intact")     func convertsAndReadsBackIntact() throws {         let dir = try TempDir()         let storeURL = dir.url.appending(path: "store.sqlite")@@ -108,9 +108,17 @@ struct V5RecordedStoreTests {         // The new table arrives empty. Seeding the default list is an         // app-bootstrap step, not part of the conversion.         #expect(try context.fetch(FetchDescriptor<WorkTypeEntity>()).isEmpty)++        // V7's additions arrive empty and nil: a V5 library holds no characters,+        // no suppressions and no extraction coverage (Req 6.8).+        #expect(try context.fetch(FetchDescriptor<CharacterRecord>()).isEmpty)+        #expect(try context.fetch(FetchDescriptor<CharacterSuppression>()).isEmpty)+        #expect(entry.characterExtractionFingerprint == nil)+        #expect(work.genericNotesExtractionFingerprint == nil)+        #expect(work.characterValues.isEmpty)     } -    @Test("The store is left recorded at 6.0.0 once it has been opened")+    @Test("The store is left recorded at 7.0.0 once it has been opened")     func openingRecordsTheNewVersion() throws {         let dir = try TempDir()         let storeURL = dir.url.appending(path: "store.sqlite")@@ -123,8 +131,8 @@ struct V5RecordedStoreTests {             withExtendedLifetime(container) {}         } -        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["6.0.0"],-                "ModelContainer.init is what runs the plan's lightweight stage")+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["7.0.0"],+                "ModelContainer.init is what runs both of the plan's lightweight stages")     }      @Test("A validator run over the converted store still finds it legal")
Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swift Added +153 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swiftnew file mode 100644index 0000000..29cc5d9--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreFixture.swift@@ -0,0 +1,153 @@+import Foundation+import SwiftData++@testable import AsterismCore++/// A store genuinely **recorded at 6.0.0**, seeded in-process through the frozen+/// `AsterismSchemaV6` snapshot.+///+/// It is the successor `docs/agent-notes/schema-migration.md` predicted: the+/// convertible input at the version every installed library actually holds when+/// `character-extraction` ships. `V5RecordedStoreFixture` keeps working beside+/// it because the plan retains the V5 stage (Q80) — the note's warning that+/// every 5.0.0-seeded fixture would become unopenable applies to a plan that+/// *drops* V5, which this one deliberately does not.+///+/// Seeding through the snapshot rather than committing another `.sqlite` is what+/// the nesting buys: a container over `AsterismSchemaV6` records 6.0.0 in the+/// store's own metadata, and the fixture cannot drift out of sync with the+/// snapshot it is built from.+enum V6RecordedStoreFixture {+    static let hostname = "frozen6.example"+    static let siteDisplayName = "Frozen Six"+    static let patternID = UUID(uuidString: "22222222-2222-2222-2222-000000000006")!+    static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-000000000006")!+    static let workID = UUID(uuidString: "44444444-4444-4444-4444-000000000006")!+    static let entryID = UUID(uuidString: "55555555-5555-5555-5555-000000000006")!+    static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-000000000006")!+    static let workTypeName = "Web Serial"+    static let captureTitle = "Chapter 9 — A Frozen Six"+    static let note = "Recorded at 6.0.0 ✓"+    static let genericNotes = "generic notes, recorded at 6.0.0"+    static let rawURLString = "https://frozen6.example/read?series=99&chapter=9"+    static let timestamp = Date(timeIntervalSince1970: 1_820_000_000)++    /// Opens a container over the frozen V6 snapshot at `storeURL`, hands its+    /// context to `seed`, saves, and releases the container so the file on disk+    /// is a closed store recorded at 6.0.0.+    static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {+        try FileManager.default.createDirectory(+            at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)+        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let configuration = ModelConfiguration(+            // The same store-configuration name `openContainer` uses; a mismatch+            // here would make the reopen create a second store.+            "AsterismV3", schema: schema, url: storeURL, cloudKitDatabase: .none)+        let container = try ModelContainer(for: schema, configurations: [configuration])+        let context = ModelContext(container)+        try seed(context)+        try context.save()+        withExtendedLifetime(container) {}+    }++    /// One row of each V6 model, on a taught Site, with the Site relationships+    /// **populated** — a V6 library has been through the relationship pass, which+    /// is what distinguishes it from the V5 fixture's deliberately nil ones.+    static func install(at storeURL: URL) throws {+        try write(at: storeURL) { context in+            let site = AsterismSchemaV6.Site()+            site.hostname = hostname+            site.displayName = siteDisplayName+            site.modeRaw = SiteMode.taught.rawValue+            context.insert(site)++            let pattern = AsterismSchemaV6.TitlePattern()+            pattern.id = patternID+            pattern.version = 4+            pattern.isActive = true+            pattern.createdAt = timestamp+            pattern.formRaw = PatternForm.phrase.rawValue+            pattern.phrasePrefix = ""+            pattern.phraseSeparator = " — "+            pattern.phraseSuffix = ""+            pattern.fieldOrderRaw = FieldOrder.chapterThenWork.rawValue+            context.insert(pattern)+            pattern.site = site++            let rule = AsterismSchemaV6.URLRulePattern()+            rule.id = urlRuleID+            rule.version = 3+            rule.isCurrent = true+            rule.createdAt = timestamp+            rule.originRaw = URLRuleOrigin.readerTaught.rawValue+            // Both fields from one rule, so the entry below can cite it for its+            // work identity *and* its chapter sequence and still replay equal —+            // which is what keeps the converted store validating cleanly.+            rule.definitionData = try JSONEncoder().encode(+                URLRuleDefinition.workAndSequence(+                    work: URLFieldSelector(locator: .query(name: ExactScalarString("series"))),+                    sequence: URLFieldSelector(locator: .query(name: ExactScalarString("chapter")))))+            context.insert(rule)+            rule.site = site++            // V6's own additions, so the conversion carries them across rather+            // than merely tolerating their absence.+            let type = AsterismSchemaV6.WorkTypeEntity()+            type.id = workTypeID+            type.name = workTypeName+            type.stateRaw = WorkTypeState.active.rawValue+            type.createdAt = timestamp+            type.modifiedAt = timestamp+            type.nameModifiedAt = timestamp+            type.stateModifiedAt = timestamp+            context.insert(type)++            let work = AsterismSchemaV6.Work()+            work.id = workID+            work.displayTitle = "A Frozen Six"+            work.siteHostname = hostname+            work.typeRaw = WorkType.other.rawValue+            work.workTypeID = workTypeID+            work.urlIdentity = "99"+            work.urlIdentityStateRaw = WorkURLIdentityState.rule.rawValue+            work.urlIdentityRuleID = urlRuleID+            work.urlIdentityRuleVersion = 3+            work.genreTags = ["frozen", "six"]+            work.genericNotes = genericNotes+            work.createdAt = timestamp+            work.modifiedAt = timestamp+            context.insert(work)+            work.site = site++            let entry = AsterismSchemaV6.Entry()+            entry.id = entryID+            entry.captureTitle = captureTitle+            entry.rawURLString = rawURLString+            entry.entryIdentityKey = rawURLString+            entry.conservativeIdentityKey = rawURLString+            entry.hostname = hostname+            entry.note = note+            entry.ratingRaw = Rating.up.rawValue+            entry.firstCapturedAt = timestamp+            entry.lastSharedAt = timestamp+            entry.modifiedAt = timestamp+            entry.chapterTitle = "Chapter 9"+            entry.chapterTitleProvenanceRaw = FieldProvenanceKind.pattern.rawValue+            entry.chapterPatternID = patternID+            entry.chapterPatternVersion = 4+            entry.chapterSequence = "9"+            entry.chapterSequenceRuleID = urlRuleID+            entry.chapterSequenceRuleVersion = 3+            entry.workAssignmentProvenanceRaw = FieldProvenanceKind.urlRule.rawValue+            entry.workURLRuleID = urlRuleID+            entry.workURLRuleVersion = 3+            entry.workURLAssignmentKindRaw = URLWorkAssignmentKind.identity.rawValue+            entry.urlWorkIdentity = "99"+            entry.urlWorkRuleID = urlRuleID+            entry.urlWorkRuleVersion = 3+            context.insert(entry)+            entry.work = work+            entry.site = site+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swift Added +144 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swiftnew file mode 100644index 0000000..fd13ad5--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V6RecordedStoreTests.swift@@ -0,0 +1,144 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++/// The V6 → V7 conversion, over a store genuinely **recorded at 6.0.0**.+///+/// This is the path every installed library takes on the update that ships+/// `character-extraction`: the store on disk was written by the V6 classes, and+/// `ModelContainer.init` runs the plan's second lightweight stage on the way in.+/// Every other store a test builds is born at 7.0.0, so a regression here would+/// otherwise only be visible on the owner's phone (Req 6.8).+@Suite("A 6.0.0-recorded store under the V7 plan", .serialized)+struct V6RecordedStoreTests {++    private final class TempDir {+        let url: URL+        init() throws {+            url = FileManager.default.temporaryDirectory.appending(+                path: "V6Recorded-\(UUID())", directoryHint: .isDirectory)+            try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)+        }+        deinit { try? FileManager.default.removeItem(at: url) }+    }++    private typealias Fixture = V6RecordedStoreFixture++    @Test("The seeded store really is recorded at 6.0.0")+    func seedIsRecordedAtSixZeroZero() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try Fixture.install(at: storeURL)++        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["6.0.0"])+    }++    @Test("openContainer converts it to 7.0.0 and reads every field back intact")+    func convertsAndReadsBackIntact() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try Fixture.install(at: storeURL)++        let container = try LibraryRepository.openContainer(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime((dir, container)) {} }++        let sites = try context.fetch(FetchDescriptor<Site>())+        let works = try context.fetch(FetchDescriptor<Work>())+        let entries = try context.fetch(FetchDescriptor<Entry>())+        let types = try context.fetch(FetchDescriptor<WorkTypeEntity>())+        let counts: [Int] = [sites.count, works.count, entries.count, types.count]+        #expect(counts == [1, 1, 1, 1])++        let work = try #require(works.first)+        #expect(work.id == Fixture.workID)+        #expect(work.displayTitle == "A Frozen Six")+        #expect(work.genericNotes == Fixture.genericNotes)+        #expect(work.genreTags == ["frozen", "six"])+        // V6's own column survives the second stage, and the relationships a V6+        // library already carries are untouched.+        #expect(work.workTypeID == Fixture.workTypeID)+        #expect(work.site?.hostname == Fixture.hostname)++        let entry = try #require(entries.first)+        #expect(entry.id == Fixture.entryID)+        #expect(entry.captureTitle == Fixture.captureTitle)+        #expect(entry.note == Fixture.note)+        #expect(entry.rating == .up)+        #expect(entry.work?.id == Fixture.workID)+        #expect(entry.site?.hostname == Fixture.hostname)++        #expect(try #require(types.first).name == Fixture.workTypeName)++        // Req 6.8: the migration carries everything forward and creates zero+        // characters. The new columns arrive nil, which is what "uncovered"+        // means — the sweep has never seen this library.+        #expect(try context.fetch(FetchDescriptor<CharacterRecord>()).isEmpty)+        #expect(try context.fetch(FetchDescriptor<CharacterSuppression>()).isEmpty)+        #expect(entry.characterExtractionFingerprint == nil)+        #expect(work.genericNotesExtractionFingerprint == nil)+        #expect(work.characterValues.isEmpty)+        #expect(work.characterSuppressionValues.isEmpty)+    }++    @Test("The store is left recorded at 7.0.0 once it has been opened")+    func openingRecordsTheNewVersion() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try Fixture.install(at: storeURL)+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["6.0.0"])++        do {+            let container = try LibraryRepository.openContainer(at: storeURL)+            _ = ModelContext(container)+            withExtendedLifetime(container) {}+        }++        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: storeURL) == ["7.0.0"],+                "ModelContainer.init is what runs the plan's second lightweight stage")+    }++    @Test("A validator run over the converted store still finds it legal")+    func convertedStoreValidates() throws {+        let dir = try TempDir()+        let storeURL = dir.url.appending(path: "store.sqlite")+        try Fixture.install(at: storeURL)++        let container = try LibraryRepository.openContainer(at: storeURL)+        let context = ModelContext(container)+        defer { withExtendedLifetime((dir, container)) {} }++        let diagnostics = try LibraryRepository.validateStore(context: context)+        #expect(diagnostics.quarantineMap().isEmpty,+                "the graph was legal when it was written at 6.0.0 and nothing was dropped")+    }++    /// Req 6.8 through the shipped door rather than the container opener: a+    /// library whose marker still records `"6"` opens, republishes `"7"`, and+    /// keeps its rows.+    @Test("The app-role open converts a \"6\"-marked library and republishes the marker")+    func appOpenConvertsAndRepublishes() async throws {+        let dir = try TempDir()+        let configuration = LibraryConfiguration(rootDirectory: dir.url)+        try Fixture.install(at: configuration.storeURL)+        try Data("6\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)++        let (result, repository) = try await LibraryRepository.openForApp(configuration)+        await repository.shutdown()++        guard case .ready(let counts) = result else {+            Issue.record("expected a ready library, got \(result)")+            return+        }+        #expect(counts.entries == 1)+        #expect(counts.works == 1)+        #expect(counts.sites == 1)+        #expect(try String(contentsOf: configuration.readinessMarkerURL, encoding: .utf8)+                .trimmingCharacters(in: .whitespacesAndNewlines) == "7")+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)+                == ["7.0.0"])+        withExtendedLifetime(dir) {}+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swiftindex b238c3d..5a0e5f5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift@@ -31,9 +31,9 @@ struct WorkTypeConvergenceTests {             directory = FileManager.default.temporaryDirectory                 .appending(path: "WorkTypeConvergence-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-            let schema = Schema(versionedSchema: AsterismSchemaV6.self)+            let schema = Schema(versionedSchema: AsterismSchemaV7.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV6MigrationPlan.self,+                for: schema, migrationPlan: AsterismV7MigrationPlan.self,                 configurations: [                     ModelConfiguration(                         "AsterismV3", schema: schema,
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swiftindex 149fa72..de04648 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift@@ -30,9 +30,9 @@ struct WorkTypeOrderingTests {             directory = FileManager.default.temporaryDirectory                 .appending(path: "WorkTypeOrdering-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-            let schema = Schema(versionedSchema: AsterismSchemaV6.self)+            let schema = Schema(versionedSchema: AsterismSchemaV7.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV6MigrationPlan.self,+                for: schema, migrationPlan: AsterismV7MigrationPlan.self,                 configurations: [                     ModelConfiguration(                         "AsterismV3", schema: schema,
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swiftindex 90f70e6..fcf4fdc 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift@@ -30,12 +30,12 @@ struct WorkTypePlumbingTests {             directory = FileManager.default.temporaryDirectory                 .appending(path: "WorkTypePlumbing-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-            let schema = Schema(versionedSchema: AsterismSchemaV6.self)+            let schema = Schema(versionedSchema: AsterismSchemaV7.self)             let configuration = ModelConfiguration(                 "AsterismV3", schema: schema,                 url: directory.appending(path: "library.store"), cloudKitDatabase: .none)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV6MigrationPlan.self,+                for: schema, migrationPlan: AsterismV7MigrationPlan.self,                 configurations: [configuration])             context = ModelContext(container)         }
Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift Modified +7 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex 5d3431b..46329cf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -268,7 +268,7 @@ struct WriteSiteRelationshipTests {     /// format change, no marker republication, no second pass.     @Test("materializeV4Payload wires both relationships from its sitesByHostname map")     func materializeWiresBothRelationships() throws {-        let schema = Schema(versionedSchema: AsterismSchemaV6.self)+        let schema = Schema(versionedSchema: AsterismSchemaV7.self)         let container = try ModelContainer(             for: schema,             configurations: [ModelConfiguration(@@ -286,18 +286,18 @@ struct WriteSiteRelationshipTests {         withExtendedLifetime(container) {}     } -    // MARK: - B1: import into a library already marked "6"+    // MARK: - B1: import into a library already marked "7"      /// The regression Decision 2 is written against. `confirmImport` reaches the     /// live store's rows directly, and nothing republishes the readiness marker     /// after it — so the relationship pass never runs again over what it wrote.     /// If the importer did not set the relationships, nothing ever would.-    @Test("Import into a \"6\"-marked library produces populated relationships")+    @Test("Import into a \"7\"-marked library produces populated relationships")     func importPopulatesRelationships() async throws {         let dir = try TempDir("WriteSiteImportFill")         let cfg = configuration(dir)         let (_, repository) = try await LibraryRepository.openForApp(cfg)-        #expect(try markerContent(cfg) == "6", "mark-at-birth certifies an empty store at \"6\"")+        #expect(try markerContent(cfg) == "7", "mark-at-birth certifies an empty store at \"7\"")          let plan = try importPlan()         let result = try await repository.confirmImport(plan: plan)@@ -305,7 +305,7 @@ struct WriteSiteRelationshipTests {             Issue.record("expected committed, got \(result)")             return         }-        #expect(try markerContent(cfg) == "6", "the import republishes nothing")+        #expect(try markerContent(cfg) == "7", "the import republishes nothing")         try expectEveryRelationshipPopulated(cfg)         withExtendedLifetime(dir) {}     }@@ -323,7 +323,7 @@ struct WriteSiteRelationshipTests {             Issue.record("expected committed, got \(result)")             return         }-        #expect(try markerContent(cfg) == "6")+        #expect(try markerContent(cfg) == "7")         try expectEveryRelationshipPopulated(cfg)         withExtendedLifetime(dir) {}     }@@ -420,7 +420,7 @@ struct WriteSiteRelationshipTests {             counts: try LibraryRepository.validateImportPlanPayloadV4(payload))     } -    /// A nonempty store certified at `"6"` — the state an import replaces into.+    /// A nonempty store certified at `"7"` — the state an import replaces into.     private func seedCertifiedLibrary(_ cfg: LibraryConfiguration, hostname: String) throws {         try FileManager.default.createDirectory(             at: cfg.storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift Added +325 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swiftnew file mode 100644index 0000000..a473733--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift@@ -0,0 +1,325 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++/// Assembly turns per-source grounded output into the rows the reader decides+/// on: one per name key per pass (Q83), routed onto an existing character or+/// not (Req 2.3), with everything already decided filtered out (Req 1.7).+@Suite("CharacterExtractionAssembler")+struct CharacterExtractionAssemblerTests {+    // MARK: - Fixtures++    static let entryA = UUID(uuidString: "00000000-0000-0000-0000-00000000A001")!+    static let entryB = UUID(uuidString: "00000000-0000-0000-0000-00000000B002")!+    static let lowUUID = UUID(uuidString: "00000000-0000-0000-0000-000000000001")!+    static let highUUID = UUID(uuidString: "FFFFFFFF-0000-0000-0000-000000000001")!++    static let revisions: [SourceRef: String] = [+        .entry(entryA): "fp-a", .entry(entryB): "fp-b", .genericNotes: "fp-g",+    ]++    static func candidate(_ name: String, aliases: [String] = [],+                          source: SourceRef = .entry(entryA),+                          quotes: [String] = []) -> GroundedCandidate {+        let key = CharacterNameKey.normalize(name)+        return GroundedCandidate(+            name: name, nameKey: key, proposedAliases: aliases, source: source,+            facts: quotes.map {+                GroundedFact(nameKey: key, statement: "says \($0)", quote: $0, source: source)+            })+    }++    static func character(_ name: String, id: UUID, retainedKey: String? = nil,+                          aliases: [String] = []) -> ExistingCharacter {+        ExistingCharacter(id: id, name: name,+                          retainedKey: retainedKey ?? CharacterNameKey.normalize(name),+                          aliases: aliases)+    }++    static func assemble(_ candidates: [GroundedCandidate],+                         context: CharacterExtractionContext = CharacterExtractionContext(),+                         pass: ExtractionPassKind = .automatic) -> [ExtractionProposal] {+        CharacterExtractionAssembler.assemble(candidates, revisions: revisions,+                                              context: context, pass: pass)+    }++    // MARK: - One row per name key (Q83)++    @Test("Candidates sharing a name key across sources become one proposal with all their facts")+    func oneProposalPerNameKey() {+        let proposals = Self.assemble([+            Self.candidate("Hanna", source: .entry(Self.entryA), quotes: ["Hanna arrives"]),+            Self.candidate("hanna", source: .entry(Self.entryB), quotes: ["hanna leaves"]),+            Self.candidate("HANNA", source: .genericNotes, quotes: ["HANNA is the lead"]),+        ])++        #expect(proposals.count == 1)+        let hanna = proposals.first+        #expect(hanna?.facts.count == 3)+        // Every revision the row rests on, so staleness is per proposal (2.7).+        #expect(hanna?.citedRevisions == Self.revisions)+        // The spelling shown is the first one the pass saw.+        #expect(hanna?.name == "Hanna")+    }++    @Test("Facts are ordered deterministically: generic notes first, then entries, then by text")+    func factOrderIsTotal() {+        let proposals = Self.assemble([+            Self.candidate("Hanna", source: .entry(Self.entryB), quotes: ["b quote"]),+            Self.candidate("Hanna", source: .genericNotes, quotes: ["g two", "g one"]),+            Self.candidate("Hanna", source: .entry(Self.entryA), quotes: ["a quote"]),+        ])++        #expect(proposals.first?.facts.map(\.quote) == ["g one", "g two", "a quote", "b quote"])+    }++    @Test("Proposals are ordered by name key, so the same pass presents the same list twice")+    func proposalOrderIsDeterministic() {+        let proposals = Self.assemble([+            Self.candidate("Zoe"), Self.candidate("Alex"), Self.candidate("Mira"),+        ])++        #expect(proposals.map(\.name) == ["Alex", "Mira", "Zoe"])+    }++    @Test("The pass output is capped by the same named constant as one response")+    func proposalsAreCapped() {+        let many = (0 ..< (CharacterExtractionBounds.maximumCandidates * 2)).map {+            Self.candidate("Name\(String(format: "%03d", $0))")+        }++        #expect(Self.assemble(many).count == CharacterExtractionBounds.maximumCandidates)+    }++    // MARK: - Matching tiers (Req 2.3, Q67)++    @Test("Matching runs current name, then retained key, then aliases")+    func matchingTiers() {+        let context = CharacterExtractionContext(characters: [+            // Renamed: its current name is Terawatt, its retained key is alex.+            Self.character("Terawatt", id: Self.lowUUID, retainedKey: CharacterNameKey.normalize("Alex")),+            Self.character("Mira", id: Self.highUUID, aliases: ["Sparks"]),+        ])++        // Each carries a fact, because a bundle with nothing new is not shown.+        func target(_ name: String) -> ExtractionProposal.Target? {+            Self.assemble([Self.candidate(name, quotes: ["a new fact"])],+                          context: context).first?.target+        }++        #expect(target("Terawatt") == .existing(Self.lowUUID))+        #expect(target("Alex") == .existing(Self.lowUUID))+        #expect(target("Sparks") == .existing(Self.highUUID))+        #expect(target("Nobody") == .newCharacter)+    }++    @Test("A current-name match beats a retained-key match, whatever the UUIDs")+    func currentNameTierWins() {+        let context = CharacterExtractionContext(characters: [+            // Holds "mira" only as its retained key, and has the lower UUID.+            Self.character("Renamed", id: Self.lowUUID, retainedKey: CharacterNameKey.normalize("Mira")),+            Self.character("Mira", id: Self.highUUID),+        ])++        #expect(Self.assemble([Self.candidate("Mira", quotes: ["a new fact"])], context: context)+            .first?.target == .existing(Self.highUUID))+    }++    @Test("Within one tier the lowest character UUID wins, so two devices agree (Q51)")+    func lowestUUIDWithinATier() {+        let context = CharacterExtractionContext(characters: [+            Self.character("Mira", id: Self.highUUID),+            Self.character("Mira", id: Self.lowUUID),+        ])++        #expect(Self.assemble([Self.candidate("Mira", quotes: ["a new fact"])], context: context)+            .first?.target == .existing(Self.lowUUID))+    }++    @Test("Proposed aliases never take part in matching, so a split half shows as new (Q93)")+    func proposedAliasesDoNotMatch() {+        let context = CharacterExtractionContext(characters: [+            Self.character("Action Girl", id: Self.lowUUID),+        ])++        let proposals = Self.assemble([Self.candidate("Hanna", aliases: ["Action Girl"])],+                                      context: context)++        #expect(proposals.first?.target == .newCharacter)+        #expect(proposals.first?.proposedAliases == ["Action Girl"])+    }++    // MARK: - Bundles carry their aliases (Q96)++    @Test("A split candidate whose name half exists becomes a bundle carrying its aliases")+    func bundleCarriesProposedAliases() {+        let context = CharacterExtractionContext(characters: [+            Self.character("Hanna", id: Self.lowUUID),+        ])++        let proposals = Self.assemble(+            [Self.candidate("Hanna", aliases: ["Action Girl"], quotes: ["Hanna arrives"])],+            context: context)++        #expect(proposals.first?.target == .existing(Self.lowUUID))+        #expect(proposals.first?.proposedAliases == ["Action Girl"])+    }++    @Test("An alias the target already holds is not proposed again")+    func knownAliasesAreNotReproposed() {+        let context = CharacterExtractionContext(characters: [+            Self.character("Hanna", id: Self.lowUUID, aliases: ["action girl"]),+        ])++        let proposals = Self.assemble(+            [Self.candidate("Hanna", aliases: ["Action Girl", "The Kite"], quotes: ["Hanna arrives"])],+            context: context)++        #expect(proposals.first?.proposedAliases == ["The Kite"])+    }++    @Test("A bundle with no new fact and no new alias is not shown (Req 1.7)")+    func emptyBundleIsNotShown() {+        let context = CharacterExtractionContext(characters: [+            Self.character("Hanna", id: Self.lowUUID),+        ])++        #expect(Self.assemble([Self.candidate("Hanna")], context: context).isEmpty)+    }++    @Test("A new candidate with no facts is still shown: the name itself is new content (Q25)")+    func nameOnlyCandidateIsShown() {+        let proposals = Self.assemble([Self.candidate("Hanna")])++        #expect(proposals.count == 1)+        #expect(proposals.first?.target == .newCharacter)+        #expect(proposals.first?.facts.isEmpty == true)+    }++    // MARK: - Resolved-key canonicalisation (Q79)++    @Test("A bundle's facts are re-keyed to the target's retained key before dedup")+    func factsAreKeyedToTheResolvedCharacter() {+        let retained = CharacterNameKey.normalize("Terawatt")+        let context = CharacterExtractionContext(+            characters: [Self.character("Terawatt", id: Self.lowUUID, retainedKey: retained,+                                        aliases: ["Terrawatt"])],+            // Already accepted under the retained key.+            acceptedFacts: [CharacterFactIdentity(nameKey: retained, source: .entry(Self.entryA),+                                         quote: "she rewires the grid")])++        // The model spelled her name the other way this time. Without the+        // canonicalisation the identity triple would differ and the fact would+        // be re-proposed.+        let proposals = Self.assemble(+            [Self.candidate("Terrawatt", source: .entry(Self.entryA),+                            quotes: ["she rewires the grid", "she vanishes"])],+            context: context)++        let bundle = proposals.first+        #expect(bundle?.target == .existing(Self.lowUUID))+        #expect(bundle?.facts.map(\.quote) == ["she vanishes"])+        #expect(bundle?.facts.allSatisfy { $0.nameKey == retained } == true)+    }++    @Test("A new candidate's facts keep its own key: canonicalisation has nothing to route to")+    func newCandidateKeepsItsOwnKey() {+        let proposals = Self.assemble([Self.candidate("Hanna", quotes: ["Hanna arrives"])])++        #expect(proposals.first?.facts.first?.nameKey == CharacterNameKey.normalize("Hanna"))+    }++    @Test("Duplicate facts within one pass collapse to one row")+    func withinPassDuplicatesCollapse() {+        let proposals = Self.assemble([+            Self.candidate("Hanna", source: .entry(Self.entryA), quotes: ["Hanna arrives"]),+            Self.candidate("Hanna", source: .entry(Self.entryA), quotes: ["Hanna arrives"]),+        ])++        #expect(proposals.first?.facts.count == 1)+    }++    // MARK: - Dedup and suppression (Req 1.7, Q47, Q49)++    @Test("An accepted fact is never re-proposed, on an automatic pass or a manual one",+          arguments: ExtractionPassKind.allCases)+    func acceptedFactsDedupOnEveryPass(pass: ExtractionPassKind) {+        let key = CharacterNameKey.normalize("Hanna")+        let context = CharacterExtractionContext(+            characters: [Self.character("Hanna", id: Self.lowUUID)],+            acceptedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+                                         quote: "Hanna arrives")])++        let proposals = Self.assemble(+            [Self.candidate("Hanna", source: .entry(Self.entryA),+                            quotes: ["Hanna arrives", "Hanna leaves"])],+            context: context, pass: pass)++        #expect(proposals.first?.facts.map(\.quote) == ["Hanna leaves"])+    }++    @Test("A suppressed fact is skipped by the sweep and re-offered by a manual pass (Q49)")+    func suppressedFactsAreAutomaticOnly() {+        let key = CharacterNameKey.normalize("Hanna")+        let context = CharacterExtractionContext(+            characters: [Self.character("Hanna", id: Self.lowUUID)],+            suppressedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+                                           quote: "Hanna arrives")])+        let candidates = [Self.candidate("Hanna", source: .entry(Self.entryA),+                                         quotes: ["Hanna arrives"])]++        #expect(Self.assemble(candidates, context: context, pass: .automatic).isEmpty)+        #expect(Self.assemble(candidates, context: context, pass: .manual).first?.facts.count == 1)+    }++    @Test("A suppressed name key blocks a new candidate on the sweep only")+    func suppressedNameKeyBlocksNewCandidates() {+        let context = CharacterExtractionContext(+            suppressedNameKeys: [CharacterNameKey.normalize("Hanna")])+        let candidates = [Self.candidate("Hanna", quotes: ["Hanna arrives"])]++        #expect(Self.assemble(candidates, context: context, pass: .automatic).isEmpty)+        #expect(Self.assemble(candidates, context: context, pass: .manual).count == 1)+    }++    @Test("A name-key suppression never blocks a bundle for an existing character (Q47)")+    func suppressedNameKeyDoesNotBlockBundles() {+        let context = CharacterExtractionContext(+            characters: [Self.character("Hanna", id: Self.lowUUID)],+            suppressedNameKeys: [CharacterNameKey.normalize("Hanna")])++        let proposals = Self.assemble([Self.candidate("Hanna", quotes: ["Hanna arrives"])],+                                      context: context, pass: .automatic)++        #expect(proposals.first?.target == .existing(Self.lowUUID))+        #expect(proposals.first?.facts.count == 1)+    }++    @Test("A candidate emptied by dedup alone is not shown, but its name-only sibling is")+    func candidateEmptiedByDedupIsNotShown() {+        let key = CharacterNameKey.normalize("Hanna")+        let context = CharacterExtractionContext(+            characters: [Self.character("Hanna", id: Self.lowUUID)],+            acceptedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+                                         quote: "Hanna arrives")])++        // Everything it had is already accepted: nothing left to decide.+        #expect(Self.assemble([Self.candidate("Hanna", source: .entry(Self.entryA),+                                              quotes: ["Hanna arrives"])],+                              context: context).isEmpty)+        // A different character, never seen, still has its name to offer.+        #expect(Self.assemble([Self.candidate("Mira")], context: context).count == 1)+    }++    // MARK: - Keys the review row displays (Q92)++    @Test("A proposal exposes the keys its row displays, name half plus proposed aliases")+    func displayedKeys() {+        let proposal = Self.assemble([Self.candidate("Hanna", aliases: ["Action Girl"])]).first++        #expect(proposal?.nameKey == CharacterNameKey.normalize("Hanna"))+        #expect(proposal?.aliasKeys == [CharacterNameKey.normalize("Action Girl")])+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift Added +464 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swiftnew file mode 100644index 0000000..7025d76--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift@@ -0,0 +1,464 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++/// The ledger is this feature's whole per-run memory: what is held, what has+/// been attempted, what the run has spent, and which sweep is allowed to keep+/// going. Nothing here is ever written to disk — an app run is the whole+/// lifetime (Q61).+///+/// `#expect` cannot hold a mutating call, so every `start`, `discard`,+/// `resignActive` and `memoryWarning` runs into a local first and the local is+/// asserted on.+@Suite("CharacterExtractionLedger")+struct CharacterExtractionLedgerTests {+    // MARK: - Fixtures++    static let workA = UUID(uuidString: "00000000-0000-0000-0000-0000000000A1")!+    static let workB = UUID(uuidString: "00000000-0000-0000-0000-0000000000B2")!+    static let entry1 = UUID(uuidString: "00000000-0000-0000-0000-000000000101")!+    static let entry2 = UUID(uuidString: "00000000-0000-0000-0000-000000000102")!+    static let characterID = UUID(uuidString: "00000000-0000-0000-0000-0000000000C3")!++    static let foreground = ModelWorkEnvironment()++    static func key(_ work: UUID = workA, _ source: SourceRef = .entry(entry1))+        -> ExtractionSourceKey {+        ExtractionSourceKey(work: work, source: source)+    }++    static func proposal(_ name: String, target: ExtractionProposal.Target = .newCharacter,+                         cites: [SourceRef: String] = [.entry(entry1): "fp-1"],+                         source: SourceRef = .entry(entry1),+                         quotes: [String] = [])+        -> ExtractionProposal {+        let key = CharacterNameKey.normalize(name)+        return ExtractionProposal(+            name: name, nameKey: key, proposedAliases: [], target: target,+            facts: quotes.map {+                GroundedFact(nameKey: key, statement: "says \($0)", quote: $0, source: source)+            },+            citedRevisions: cites)+    }++    /// A ledger mid-sweep with one attempt running for `key`.+    static func running(_ key: ExtractionSourceKey = key(), fingerprint: String = "fp-1",+                        pass: ExtractionPassKind = .automatic) -> CharacterExtractionLedger {+        var ledger = CharacterExtractionLedger()+        ledger.beginSweep()+        let outcome = ledger.start(key, fingerprint: fingerprint, pass: pass,+                                   environment: foreground)+        #expect(outcome == .start)+        return ledger+    }++    // MARK: - Single flight (Req 1.3)++    @Test("Only one attempt runs at a time; a second source waits for the next round")+    func singleFlight() {+        var ledger = Self.running()++        let outcome = ledger.start(Self.key(Self.workB, .genericNotes), fingerprint: "fp-b",+                                   pass: .automatic, environment: Self.foreground)++        #expect(outcome == .refuse(.inFlight(Self.key())))+        #expect(ledger.inFlight?.key == Self.key())+    }++    @Test("The same work is never processed twice concurrently")+    func sameWorkNotProcessedTwice() {+        var ledger = Self.running()++        let outcome = ledger.start(Self.key(Self.workA, .entry(Self.entry2)), fingerprint: "fp-2",+                                   pass: .automatic, environment: Self.foreground)++        #expect(outcome == .refuse(.inFlight(Self.key())))+    }++    // MARK: - Attempt memory (Req 1.8, Q69)++    @Test("A failed or timed-out source is not retried this run",+          arguments: [ExtractionSettlement.failed, .timedOut])+    func failuresAreRemembered(settlement: ExtractionSettlement) {+        var ledger = Self.running()+        ledger.settle(Self.key(), settlement, modelPhase: .seconds(3))+        let retry = ledger.start(Self.key(), fingerprint: "fp-1", pass: .automatic,+                                 environment: Self.foreground)++        #expect(ledger.isAttempted(Self.key()))+        #expect(retry == .refuse(.attempted))+    }++    @Test("A pre-empted attempt charges what it spent but is not marked attempted (Q69)")+    func cancellationIsNotAnAttempt() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .cancelled, modelPhase: .seconds(4))+        let retry = ledger.start(Self.key(), fingerprint: "fp-1", pass: .automatic,+                                 environment: Self.foreground)++        #expect(!ledger.isAttempted(Self.key()), "the source did nothing wrong")+        #expect(ledger.budgetSpent == .seconds(4), "an automatic attempt charges what it spent")+        #expect(retry == .start)+    }++    @Test("A source that produced nothing is settled and not retried")+    func producedNoneIsASettlement() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([]), modelPhase: .seconds(2))++        #expect(ledger.isAttempted(Self.key()))+        #expect(ledger.held(for: Self.workA).isEmpty)+        #expect(ledger.budgetSpent == .seconds(2))+    }++    @Test("Every automatic settlement charges the budget, and exhaustion refuses the sweep only")+    func budgetChargingAndExhaustion() {+        var ledger = CharacterExtractionLedger()+        ledger.beginSweep()+        var spent: Duration = .zero+        var sources = 0+        while !ledger.budgetExhausted {+            let key = ExtractionSourceKey(work: Self.workA, source: .entry(UUID()))+            let started = ledger.start(key, fingerprint: "fp", pass: .automatic,+                                       environment: Self.foreground)+            #expect(started == .start)+            ledger.settle(key, .failed, modelPhase: .seconds(10))+            spent += .seconds(10)+            sources += 1+            #expect(sources < 100, "the budget must be reachable")+        }+        let sweep = ledger.start(Self.key(Self.workB), fingerprint: "fp", pass: .automatic,+                                 environment: Self.foreground)+        let manual = ledger.start(Self.key(Self.workB), fingerprint: "fp", pass: .manual,+                                  environment: Self.foreground)++        #expect(ledger.budgetSpent == spent)+        #expect(spent >= CharacterExtractionBounds.runTimeBudget)+        #expect(sweep == .refuse(.budgetExhausted))+        // Req 1.11: the reader's own pass starts anyway.+        #expect(manual == .start)+    }++    /// Q101: the budget bounds the *sweep*. A manual pass runs regardless of it,+    /// so charging it would let one reader-initiated pass spend the sweep's+    /// remaining time and silently end automatic extraction for the app run.+    @Test("A manual pass spends none of the run budget; an automatic one spends all of it")+    func manualSettlementDoesNotCharge() {+        var manualLedger = Self.running(Self.key(), pass: .manual)+        manualLedger.settle(Self.key(), .proposals([Self.proposal("Hanna")]),+                            modelPhase: .seconds(9))+        #expect(manualLedger.budgetSpent == .zero)+        #expect(manualLedger.held(for: Self.workA).count == 1, "it still holds what it found")++        var sweepLedger = Self.running(Self.key(), pass: .automatic)+        sweepLedger.settle(Self.key(), .proposals([Self.proposal("Hanna")]),+                           modelPhase: .seconds(9))+        #expect(sweepLedger.budgetSpent == .seconds(9))+    }++    // MARK: - Device gates (Req 1.2)++    @Test("The sweep is gated on the app being active and the device being willing",+          arguments: zip(+              [ModelWorkEnvironment(isActive: false),+               ModelWorkEnvironment(isLowPowerMode: true),+               ModelWorkEnvironment(thermalState: .serious),+               ModelWorkEnvironment(thermalState: .critical)],+              [ExtractionRefusalReason.notActive, .lowPower,+               .thermallyConstrained, .thermallyConstrained]))+    func sweepGates(environment: ModelWorkEnvironment, reason: ExtractionRefusalReason) {+        var ledger = CharacterExtractionLedger()+        ledger.beginSweep()+        let sweep = ledger.start(Self.key(), fingerprint: "fp-1", pass: .automatic,+                                 environment: environment)+        let manual = ledger.start(Self.key(), fingerprint: "fp-1", pass: .manual,+                                  environment: environment)++        #expect(sweep == .refuse(reason))+        // The gates are the device's answer to background work; a reader+        // waiting on the answer is not background work.+        #expect(manual == .start)+    }++    @Test("A fair thermal state does not stop the sweep")+    func fairThermalIsFine() {+        var ledger = CharacterExtractionLedger()+        ledger.beginSweep()+        let outcome = ledger.start(Self.key(), fingerprint: "fp-1", pass: .automatic,+                                   environment: ModelWorkEnvironment(thermalState: .fair))++        #expect(outcome == .start)+    }++    // MARK: - The manual pass (Req 1.11)++    @Test("A manual pass ignores attempt memory and pre-empts the sweep's attempt")+    func manualPassPreempts() {+        var ledger = Self.running()+        let preempt = ledger.start(Self.key(Self.workB), fingerprint: "fp-b", pass: .manual,+                                   environment: Self.foreground)+        #expect(preempt == .preempt(Self.key()))+        #expect(!ledger.sweepActive, "the pre-emption is also the sweep's stop signal")++        // Two-step, like rule suggestion's: the cancelled attempt settles+        // itself, and only then does the manual claim get its start.+        ledger.settle(Self.key(), .cancelled, modelPhase: .seconds(1))+        let started = ledger.start(Self.key(Self.workB), fingerprint: "fp-b", pass: .manual,+                                   environment: Self.foreground)+        #expect(started == .start)+    }++    @Test("A manual pass re-attempts a source the sweep already failed on")+    func manualIgnoresAttemptMemory() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .failed, modelPhase: .seconds(3))+        let outcome = ledger.start(Self.key(), fingerprint: "fp-1", pass: .manual,+                                   environment: Self.foreground)++        #expect(outcome == .start)+    }++    @Test("A second manual pass waits rather than pre-empting the first")+    func manualDoesNotPreemptManual() {+        var ledger = Self.running(Self.key(), pass: .manual)+        let outcome = ledger.start(Self.key(Self.workB), fingerprint: "fp-b", pass: .manual,+                                   environment: Self.foreground)++        #expect(outcome == .refuse(.inFlight(Self.key())))+    }++    // MARK: - Held proposals (Q61, Req 2.6)++    @Test("Proposals are held per work and survive until decided")+    func heldPerWork() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna"), Self.proposal("Grover")]),+                      modelPhase: .seconds(5))++        #expect(ledger.held(for: Self.workA).map(\.name) == ["Hanna", "Grover"])+        #expect(ledger.held(for: Self.workB).isEmpty)+        #expect(ledger.worksWithProposals == [Self.workA])+    }++    @Test("A later settlement for another name key appends rather than replacing")+    func settlementsAccumulate() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))++        let second = Self.key(Self.workA, .genericNotes)+        let started = ledger.start(second, fingerprint: "fp-g", pass: .automatic,+                                   environment: Self.foreground)+        #expect(started == .start)+        ledger.settle(second, .proposals([Self.proposal("Grover")]), modelPhase: .seconds(1))++        #expect(ledger.held(for: Self.workA).map(\.name) == ["Hanna", "Grover"])+    }++    /// Q100: the held list's grain is one row per name key per work, the same+    /// grain Q83 gives a pass. Two sources naming one character used to append+    /// two rows of one key — the character shown twice, and `discard` removing+    /// only the first of them.+    @Test("Two sources of one work naming one character hold a single merged row")+    func settlementsMergeByNameKey() {+        var ledger = Self.running()+        ledger.settle(+            Self.key(),+            .proposals([Self.proposal("Hanna", quotes: ["Hanna led the squad"])]),+            modelPhase: .seconds(1))++        let second = Self.key(Self.workA, .genericNotes)+        let started = ledger.start(second, fingerprint: "fp-g", pass: .automatic,+                                   environment: Self.foreground)+        #expect(started == .start)+        ledger.settle(+            second,+            .proposals([Self.proposal("Hanna", cites: [.genericNotes: "fp-g"],+                                      source: .genericNotes, quotes: ["Hanna flies"])]),+            modelPhase: .seconds(1))++        let held = ledger.held(for: Self.workA)+        #expect(held.count == 1, "one candidate per name key, however many sources named it")+        // Nothing either source contributed is lost, and staleness covers both+        // revisions the row now rests on.+        #expect(held.first?.facts.map(\.quote) == ["Hanna flies", "Hanna led the squad"])+        #expect(held.first?.citedRevisions+                == [.entry(Self.entry1): "fp-1", .genericNotes: "fp-g"])++        let discarded = ledger.discard(nameKey: CharacterNameKey.normalize("Hanna"),+                                       for: Self.workA)+        #expect(discarded)+        #expect(ledger.held(for: Self.workA).isEmpty, "one decision decides the whole row")+    }++    @Test("A decided proposal is discarded by its name key, and the rest stay")+    func discardOneProposal() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna"), Self.proposal("Grover")]),+                      modelPhase: .seconds(1))++        let discardedHanna = ledger.discard(nameKey: CharacterNameKey.normalize("Hanna"), for: Self.workA)+        let discardedNobody = ledger.discard(nameKey: "nobody", for: Self.workA)+        #expect(discardedHanna)+        #expect(!discardedNobody)+        #expect(ledger.held(for: Self.workA).map(\.name) == ["Grover"])++        let discardedGrover = ledger.discard(nameKey: CharacterNameKey.normalize("Grover"),+                                             for: Self.workA)+        #expect(discardedGrover)+        #expect(ledger.worksWithProposals.isEmpty, "an emptied work leaves no indicator behind")+    }++    // MARK: - Reconcile invalidation (Req 2.8)++    @Test("A deleted work takes its proposals and its attempt memory with it")+    func deletedWorkIsDropped() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))++        let invalidated = ledger.reconcile(against: [:])++        #expect(invalidated == [Self.workA])+        #expect(ledger.held(for: Self.workA).isEmpty)+        #expect(!ledger.isAttempted(Self.key()), "the work is gone; so is what it was owed")+    }++    @Test("A bundle whose target character was deleted is discarded")+    func deletedCharacterDropsItsBundle() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([+            Self.proposal("Hanna", target: .existing(Self.characterID)),+            Self.proposal("Grover"),+        ]), modelPhase: .seconds(1))++        let invalidated = ledger.reconcile(against: [+            Self.workA: WorkExtractionState(revisions: [.entry(Self.entry1): "fp-1"],+                                            characterIDs: []),+        ])++        #expect(invalidated == [Self.workA])+        #expect(ledger.held(for: Self.workA).map(\.name) == ["Grover"])+    }++    @Test("A proposal whose cited revision changed under it is discarded, and its siblings are not")+    func changedCorpusDropsOnlyWhatItTouched() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([+            Self.proposal("Hanna", cites: [.entry(Self.entry1): "fp-1"]),+            Self.proposal("Grover", cites: [.genericNotes: "fp-g"]),+            // Cites both, so one changed revision is enough (Q83, Req 2.7).+            Self.proposal("Klar", cites: [.entry(Self.entry1): "fp-1", .genericNotes: "fp-g"]),+        ]), modelPhase: .seconds(1))++        let invalidated = ledger.reconcile(against: [+            Self.workA: WorkExtractionState(+                revisions: [.entry(Self.entry1): "fp-1-edited", .genericNotes: "fp-g"],+                characterIDs: []),+        ])++        #expect(invalidated == [Self.workA])+        #expect(ledger.held(for: Self.workA).map(\.name) == ["Grover"])+    }++    @Test("A proposal citing a source that has gone away is discarded")+    func deletedSourceDropsItsProposal() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))++        _ = ledger.reconcile(against: [+            Self.workA: WorkExtractionState(revisions: [:], characterIDs: []),+        ])++        #expect(ledger.held(for: Self.workA).isEmpty)+    }++    @Test("An unchanged corpus invalidates nothing")+    func unchangedCorpusIsLeftAlone() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))++        let invalidated = ledger.reconcile(against: [+            Self.workA: WorkExtractionState(revisions: [.entry(Self.entry1): "fp-1"],+                                            characterIDs: []),+        ])++        #expect(invalidated.isEmpty)+        #expect(ledger.held(for: Self.workA).count == 1)+        #expect(ledger.isAttempted(Self.key()))+    }++    @Test("An in-flight attempt whose source changed is voided, and its result thrown away")+    func inFlightIsVoidedByAChangedSource() {+        var ledger = Self.running()++        let invalidated = ledger.reconcile(against: [+            Self.workA: WorkExtractionState(revisions: [.entry(Self.entry1): "fp-1-edited"],+                                            characterIDs: []),+        ])++        #expect(invalidated == [Self.workA])+        #expect(ledger.inFlight?.voided == true)++        // It still settles — the coordinator owns the Task — but what it+        // returns is about a source that no longer exists.+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(3))+        #expect(ledger.held(for: Self.workA).isEmpty)+        #expect(!ledger.isAttempted(Self.key()), "a voided attempt is a cancellation")+        #expect(ledger.budgetSpent == .seconds(3), "it still spent what it spent")+    }++    // MARK: - Sweep generations++    @Test("One sweep at a time, and only its own token may end it")+    func sweepGenerations() {+        var ledger = CharacterExtractionLedger()+        let first = ledger.beginSweep()+        let second = ledger.beginSweep()+        #expect(first != nil)+        #expect(second == nil, "a second sweep under the first would double-process")+        #expect(ledger.isSweeping(generation: first ?? 0))++        // A stale token ends nothing.+        ledger.endSweep(generation: (first ?? 0) + 99)+        #expect(ledger.sweepActive)++        ledger.endSweep(generation: first ?? 0)+        #expect(!ledger.sweepActive)+        let third = ledger.beginSweep()+        #expect(third != nil && third != first)+    }++    @Test("Resigning active stops the sweep and hands back its attempt to cancel")+    func resignActive() {+        var ledger = Self.running()+        let cancelling = ledger.resignActive()++        #expect(cancelling == Self.key())+        #expect(!ledger.sweepActive)++        // A manual pass is the reader's and is not stopped by the app going away.+        var manual = Self.running(Self.key(), pass: .manual)+        let manualCancelling = manual.resignActive()+        #expect(manualCancelling == nil)+    }++    @Test("A memory warning drops the run's cheap state and voids what is in flight")+    func memoryWarning() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))+        let second = Self.key(Self.workA, .genericNotes)+        _ = ledger.start(second, fingerprint: "fp-g", pass: .automatic,+                         environment: Self.foreground)++        let cancelling = ledger.memoryWarning()++        #expect(cancelling == second)+        #expect(ledger.held(for: Self.workA).isEmpty)+        #expect(!ledger.isAttempted(Self.key()))+        #expect(ledger.inFlight?.voided == true)+        // Held proposals lost this way are re-derivable: their revisions were+        // never covered (Req 2.6).+        #expect(ledger.worksWithProposals.isEmpty)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift Added +336 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swiftnew file mode 100644index 0000000..7e1d14c--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift@@ -0,0 +1,336 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++/// Grounding is the only mechanical check that the model did not invent+/// anything (Req 1.6, Q14), so it is tested the way a checker should be: over+/// generated (note, output) pairs, asserting invariants rather than+/// hand-picked outputs.+@Suite("CharacterGrounding")+struct CharacterGroundingTests {+    // MARK: - Generators++    /// A reproducible generator. Property-style tests must fail the same way+    /// twice or the failure cannot be investigated.+    struct Seeded: RandomNumberGenerator {+        private var state: UInt64+        init(seed: UInt64) { state = seed &* 6_364_136_223_846_793_005 &+ 1 }+        mutating func next() -> UInt64 {+            state ^= state << 13+            state ^= state >> 7+            state ^= state << 17+            return state+        }+    }++    static let names = ["Hanna", "Bruce", "Grover", "Terawatt", "Alex", "Jo Lupo", "The Crowned One"]+    static let absentNames = ["Zelmira", "Quorra", "the general", "redevelopment law"]+    static let verbs = ["fights", "hides", "argues with the council", "loses a hand", "returns home"]++    /// One generated note plus the model output claimed about it, half of it+    /// deliberately ungrounded.+    struct Sample {+        var source: ExtractionSource+        var output: ExtractionResult+        /// The quotes that really are in the note.+        var groundedQuotes: Set<String>+    }++    static func sample(seed: UInt64) -> Sample {+        var rng = Seeded(seed: seed)+        var sentences: [String] = []+        var present: [String] = []+        for name in names where Bool.random(using: &rng) {+            present.append(name)+            sentences.append("\(name) \(verbs.randomElement(using: &rng)!).")+        }+        if present.isEmpty {+            present.append(names[0])+            sentences.append("\(names[0]) \(verbs[0]).")+        }+        let text = sentences.joined(separator: " ")++        var characters: [ExtractedCharacter] = []+        var groundedQuotes: Set<String> = []+        for name in present {+            var facts: [ExtractedFact] = []+            // A real quote, lifted out of the note verbatim.+            if let real = sentences.first(where: { $0.hasPrefix(name) }) {+                facts.append(ExtractedFact(statement: "\(name) does something", quote: real))+                groundedQuotes.insert(real)+            }+            // An invented one, which grounding must throw away.+            if Bool.random(using: &rng) {+                facts.append(ExtractedFact(statement: "\(name) is secretly a king",+                                           quote: "\(name) wears a crown of stars"))+            }+            characters.append(ExtractedCharacter(name: name, facts: facts))+        }+        // Names the note never mentions, which must not survive either.+        for name in absentNames where Bool.random(using: &rng) {+            characters.append(ExtractedCharacter(+                name: name, facts: [ExtractedFact(statement: "\(name) exists", quote: text)]))+        }++        return Sample(source: source(text: text), output: ExtractionResult(characters: characters),+                      groundedQuotes: groundedQuotes)+    }++    static let workID = UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!++    static func source(text: String, ref: SourceRef = .genericNotes,+                       title: String = "A Serial Story",+                       fingerprint: String = "fp") -> ExtractionSource {+        ExtractionSource(workID: workID, workTitle: title, source: ref,+                         text: text, fingerprint: fingerprint)+    }++    static func contains(_ needle: String, in haystack: String) -> Bool {+        haystack.precomposedStringWithCanonicalMapping+            .range(of: needle.precomposedStringWithCanonicalMapping,+                   options: [.caseInsensitive]) != nil+    }++    // MARK: - Properties (Req 1.6, 1.10)++    @Test("Every surviving quote is a verbatim substring of the source it cites",+          arguments: 1 ... 40)+    func quotesAreVerbatim(seed: Int) {+        let sample = Self.sample(seed: UInt64(seed))+        let outcome = CharacterGrounding.ground(sample.output, from: sample.source)++        for candidate in outcome.candidates {+            for fact in candidate.facts {+                #expect(Self.contains(fact.quote, in: sample.source.text),+                        "\"\(fact.quote)\" is not in the note")+                #expect(fact.source == sample.source.source)+            }+        }+        // And the invented ones really were dropped, not merely absent by luck.+        let kept = Set(outcome.candidates.flatMap { $0.facts.map(\.quote) })+        #expect(kept.isSubset(of: sample.groundedQuotes))+    }++    @Test("No candidate survives whose name does not appear in the source", arguments: 1 ... 40)+    func absentNamesAreDropped(seed: Int) {+        let sample = Self.sample(seed: UInt64(seed))+        let outcome = CharacterGrounding.ground(sample.output, from: sample.source)++        for candidate in outcome.candidates {+            #expect(Self.contains(candidate.name, in: sample.source.text))+            for alias in candidate.proposedAliases {+                #expect(Self.contains(alias, in: sample.source.text),+                        "a proposed alias must ground too (Decision 5)")+            }+        }+    }++    @Test("The output caps hold whatever the model returns", arguments: 1 ... 20)+    func capsHold(seed: Int) {+        // A note that grounds everything, so only the caps can bound the output.+        let filler = (0 ..< 60).map { "Name\($0) walks the long road." }+        let text = filler.joined(separator: " ")+        let characters = (0 ..< 60).map { index in+            ExtractedCharacter(+                name: "Name\(index)",+                facts: (0 ..< 30).map { _ in+                    ExtractedFact(statement: "walks", quote: filler[index])+                })+        }+        var rng = Seeded(seed: UInt64(seed))+        let shuffled = characters.shuffled(using: &rng)++        let outcome = CharacterGrounding.ground(ExtractionResult(characters: shuffled),+                                                from: Self.source(text: text))++        #expect(outcome.candidates.count <= CharacterExtractionBounds.maximumCandidates)+        for candidate in outcome.candidates {+            #expect(candidate.facts.count <= CharacterExtractionBounds.maximumFactsPerCandidate)+            #expect(candidate.name.count <= CharacterExtractionBounds.maximumNameLength)+            for fact in candidate.facts {+                #expect(fact.quote.count <= CharacterExtractionBounds.maximumEvidenceLength)+                #expect(fact.statement.count <= CharacterExtractionBounds.maximumStatementLength)+            }+        }+    }++    @Test("Over-long names, statements and quotes are dropped rather than truncated")+    func overLongValuesAreDropped() {+        let longName = String(repeating: "a", count: CharacterExtractionBounds.maximumNameLength + 1)+        let longQuote = String(repeating: "b", count: CharacterExtractionBounds.maximumEvidenceLength + 1)+        let longStatement = String(repeating: "c",+                                   count: CharacterExtractionBounds.maximumStatementLength + 1)+        let text = "\(longName) appears. \(longQuote). Hanna waits."+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: longName, facts: []),+            ExtractedCharacter(name: "Hanna", facts: [+                ExtractedFact(statement: "waits", quote: longQuote),+                ExtractedFact(statement: longStatement, quote: "Hanna waits"),+                ExtractedFact(statement: "waits", quote: "Hanna waits"),+            ]),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.count == 1)+        let hanna = outcome.candidates.first+        #expect(hanna?.name == "Hanna")+        // Only the one fact that is inside both caps survives; nothing is cut+        // down to fit, because a truncated quote is no longer verbatim.+        #expect(hanna?.facts.map(\.quote) == ["Hanna waits"])+        #expect(outcome.drops.contains { $0.reason == .nameTooLong })+        #expect(outcome.drops.contains { $0.reason == .quoteTooLong })+        #expect(outcome.drops.contains { $0.reason == .statementTooLong })+    }++    @Test("A name-only candidate is kept; a candidate with no name is not (Q25)")+    func nameOnlyCandidatesSurvive() {+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "Hanna", facts: []),+            ExtractedCharacter(name: "   ", facts: []),+            ExtractedCharacter(name: "Bruce", facts: [ExtractedFact(statement: "x", quote: "  ")]),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: "Hanna and Bruce."))++        #expect(outcome.candidates.map(\.name) == ["Hanna", "Bruce"])+        #expect(outcome.candidates.allSatisfy { $0.facts.isEmpty })+        #expect(outcome.drops.contains { $0.reason == .emptyName })+        #expect(outcome.drops.contains { $0.reason == .emptyQuote })+    }++    @Test("Grounding is case-insensitive and composition-insensitive, never letter-insensitive")+    func groundingFolding() {+        let text = "RENÉE shouted. Someone answered."+        let output = ExtractionResult(characters: [+            // Decomposed é, lower case: the same name after NFC + case folding.+            ExtractedCharacter(name: "rene\u{0301}e",+                               facts: [ExtractedFact(statement: "shouts", quote: "rene\u{0301}e shouted")]),+            // A different letter entirely: never grounded.+            ExtractedCharacter(name: "Renee", facts: []),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.count == 1)+        #expect(outcome.candidates.first?.facts.count == 1)+    }++    // MARK: - Name keys (Q41, Q64, Q99)++    /// Idempotence is the property Q99 turned the recipe over for: a combine+    /// stores a retained key as a bare alias string and alias matching+    /// re-normalises it, so a key that moves on re-normalisation stops routing.+    /// The repeated-article inputs are the ones a single strip got wrong.+    @Test("The name key folds case without a locale and is idempotent",+          arguments: ["Hanna", "HANNA", "hanna", " Hanna ", "İstanbul", "ISTANBUL", "Ïsolde",+                      "The Crowned One", "the the Crowned One", "The The The Queen"])+    func nameKeyIsStable(name: String) {+        let key = CharacterNameKey.normalize(name)+        #expect(CharacterNameKey.normalize(key) == key, "keying a key must change nothing")+        #expect(key == key.trimmingCharacters(in: .whitespacesAndNewlines))+    }++    @Test("Case folding is locale-independent, so two devices agree about the Turkish I")+    func turkishIIsStable() {+        // The point of `locale: nil`: a device set to Turkish must fold `I` the+        // way every other device does, or the same character keys differently+        // on two devices and stops matching.+        #expect(CharacterNameKey.normalize("ILSE") == CharacterNameKey.normalize("ilse"))+        #expect(CharacterNameKey.normalize("Irmak") == CharacterNameKey.normalize("IRMAK"))+        // ...and the dotless ı stays a different name.+        #expect(CharacterNameKey.normalize("Irmak") != CharacterNameKey.normalize("ırmak"))+    }++    @Test("Leading English articles are stripped until the prefix is gone (Q64/Q99)")+    func leadingArticleIsStripped() {+        #expect(CharacterNameKey.normalize("The Crowned One") == CharacterNameKey.normalize("crowned one"))+        // Q99 amends Q64's "one article": stripping repeats, because a key that+        // moved on re-normalisation would stop routing a combine's aliases.+        #expect(CharacterNameKey.normalize("the the Crowned One") == "crowned one")+        // Not a word boundary: "Theodore" keeps its head.+        #expect(CharacterNameKey.normalize("Theodore") == "theodore")+        #expect(CharacterNameKey.normalize("The") == "the", "a bare article is a name, not a prefix")+    }++    // MARK: - Slash split (Decision 5, Q90)++    @Test("A slash-compound whose components all ground becomes name plus proposed aliases")+    func slashSplit() {+        let text = "Hanna arrives. Everyone calls her Action Girl now."+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "Hanna/Action Girl",+                               facts: [ExtractedFact(statement: "arrives", quote: "Hanna arrives")]),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        let candidate = outcome.candidates.first+        #expect(candidate?.name == "Hanna")+        #expect(candidate?.proposedAliases == ["Action Girl"])+        #expect(candidate?.nameKey == CharacterNameKey.normalize("Hanna"))+    }++    @Test("A multi-slash compound splits into the first name and every other component")+    func multiSlashSplit() {+        let text = "Grover, or Klar, or the Warden, depending who is asking."+        let output = ExtractionResult(characters: [+            ExtractedCharacter(name: "Grover/Klar/the Warden", facts: []),+        ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.first?.name == "Grover")+        #expect(outcome.candidates.first?.proposedAliases == ["Klar", "the Warden"])+    }++    @Test("A component that does not ground cancels the split, and the compound then fails 1.6")+    func nonGroundingComponentCancelsTheSplit() {+        // "Bruce" is there, "Batman" is not — so no split. The compound name+        // itself is not in the note either, so the candidate dies on the+        // ordinary name check rather than arriving under a name that would+        // match nothing later.+        let outcome = CharacterGrounding.ground(+            ExtractionResult(characters: [ExtractedCharacter(name: "Bruce/Batman", facts: [])]),+            from: Self.source(text: "Bruce broods."))++        #expect(outcome.candidates.isEmpty)+        #expect(outcome.drops.contains { $0.reason == .nameNotInSource })+    }++    @Test("A compound the note writes as one string still splits; the strike is the veto (Q92)")+    func compoundWrittenInTheNoteStillSplits() {+        // Substring grounding cannot tell "Bruce/Batman as one label" from+        // "Bruce, also called Batman" — every component of a grounded compound+        // grounds by construction. Decision 5 accepts that and puts the veto on+        // the review row instead of in the checker.+        let outcome = CharacterGrounding.ground(+            ExtractionResult(characters: [ExtractedCharacter(name: "Bruce/Batman", facts: [])]),+            from: Self.source(text: "The file is labelled Bruce/Batman."))++        #expect(outcome.candidates.map(\.name) == ["Bruce"])+        #expect(outcome.candidates.first?.proposedAliases == ["Batman"])+    }++    @Test("An empty component is not a split: the compound name stands or falls whole")+    func emptyComponentIsNotASplit() {+        let outcome = CharacterGrounding.ground(+            ExtractionResult(characters: [ExtractedCharacter(name: "Hanna/", facts: [])]),+            from: Self.source(text: "The sign reads Hanna/ and nothing else."))++        #expect(outcome.candidates.map(\.name) == ["Hanna/"])+        #expect(outcome.candidates.first?.proposedAliases.isEmpty == true)+    }++    @Test("A component repeating the name half is not proposed as an alias of itself")+    func aliasEqualToTheNameIsDropped() {+        let outcome = CharacterGrounding.ground(+            ExtractionResult(characters: [ExtractedCharacter(name: "Hanna/hanna", facts: [])]),+            from: Self.source(text: "Hanna, or hanna, as the note spells her."))++        #expect(outcome.candidates.first?.name == "Hanna")+        #expect(outcome.candidates.first?.proposedAliases.isEmpty == true)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift Added +199 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swiftnew file mode 100644index 0000000..beed98f--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift@@ -0,0 +1,199 @@+import Foundation+import FoundationModels+import Testing++@testable import AsterismIntelligence++@Suite("FoundationCharacterExtractionModelClient")+struct FoundationCharacterExtractionModelClientTests {+    static let source = ExtractionSource(+        workID: UUID(uuidString: "00000000-0000-0000-0000-0000000000AA")!,+        workTitle: "Sylver Seeker",+        source: .entry(UUID(uuidString: "00000000-0000-0000-0000-0000000000BB")!),+        text: """+        Hanna finally reaches the tower and finds Grover waiting at the top. \+        She tells him the council has already voted, and he refuses to believe \+        her. Hanna leaves before dawn.+        """,+        fingerprint: "fp")++    // MARK: - Availability++    @Test("Availability maps to the feature's own gate, reason included for logging")+    func availabilityMapping() {+        #expect(FoundationModelDiagnostics.availability(from: .available) == .available)+        for reason: SystemLanguageModel.Availability.UnavailableReason in+            [.deviceNotEligible, .appleIntelligenceNotEnabled, .modelNotReady] {+            let mapped = FoundationModelDiagnostics.availability(from: .unavailable(reason))+            #expect(!mapped.isAvailable)+        }+    }++    @Test("Both clients answer the availability question identically")+    func bothClientsAgree() {+        #expect(FoundationCharacterExtractionModelClient().availability()+            == FoundationRuleSuggestionModelClient().availability())+    }++    // MARK: - Instructions (Q55)++    @Test("The instructions ask for named story characters and forbid invention")+    func instructions() {+        let instructions = FoundationCharacterExtractionModelClient.instructions++        #expect(instructions.contains("named story characters"))+        #expect(instructions.contains("verbatim"))+        #expect(instructions.contains("Never invent"))+        // The junk tail the prototype produced is named, so the model is told+        // what does not count (Q55).+        #expect(instructions.contains("everyone"))+        #expect(instructions.contains("the general"))+        // Q25: a name with no supportable fact is still a candidate.+        #expect(instructions.contains("reported with no facts"))+    }++    // MARK: - The request carries one source and the title, nothing else (Req 1.4)++    @Test("The prompt carries the display title and the source text, and nothing else")+    func promptCarriesOnlyTheSource() {+        let prompt = FoundationCharacterExtractionModelClient.prompt(for: Self.source)++        #expect(prompt.contains("Sylver Seeker"))+        #expect(prompt.contains(Self.source.text))+        // No identifiers travel: the citation is which source the system sent,+        // never something the model could echo back (Q15).+        #expect(!prompt.contains(Self.source.workID.uuidString))+        #expect(!prompt.contains("fp"))+    }++    @Test("Generic notes are described as such, so the model is not told they are a chapter")+    func genericNotesArePromptedDifferently() {+        var generic = Self.source+        generic.source = .genericNotes++        let entryPrompt = FoundationCharacterExtractionModelClient.prompt(for: Self.source)+        let genericPrompt = FoundationCharacterExtractionModelClient.prompt(for: generic)++        #expect(entryPrompt.contains("about one chapter"))+        #expect(genericPrompt.contains("general notes"))+        #expect(entryPrompt != genericPrompt)+    }++    // MARK: - Generation options (Decision 3)++    @Test("Sampling is greedy so the same source yields the same output")+    func greedyOptions() {+        #expect(FoundationCharacterExtractionModelClient.generationOptions+            == GenerationOptions(sampling: .greedy))+    }++    @Test("A context-window overflow is recognisable by type, so the source can be skipped")+    func contextOverflowIsTyped() {+        let client = FoundationCharacterExtractionModelClient()+        let overflow = LanguageModelSession.GenerationError+            .exceededContextWindowSize(.init(debugDescription: "too long"))+        let refusal = LanguageModelSession.GenerationError+            .guardrailViolation(.init(debugDescription: "nope"))++        #expect(client.isContextWindowOverflow(overflow))+        #expect(!client.isContextWindowOverflow(refusal))+        #expect(!client.isContextWindowOverflow(CancellationError()))+        #expect(client.describe(refusal).contains("guardrailViolation"))+    }++    // MARK: - One live call++    /// Every `GenerationError` except `decodingFailure` is the host talking,+    /// not this client. None of them says the response failed to become an+    /// `ExtractionResult` — which is the one thing this test exists to pin, and+    /// the one case left able to fail it.+    static func isTransientModelError(_ error: (any Error)?) -> Bool {+        guard let generation = error as? LanguageModelSession.GenerationError else { return false }+        if case .decodingFailure = generation { return false }+        return true+    }++    @Test("A live model call returns a decodable ExtractionResult for one note")+    func liveCall() async throws {+        let client = FoundationCharacterExtractionModelClient()+        try await withKnownIssue("The on-device model is unavailable on this host") {+            try #require(SystemLanguageModel.default.isAvailable)+            #expect(client.availability() == .available)++            // The availability check above is a snapshot taken before the call;+            // the call itself can still come back rate-limited, refused by the+            // guardrails (11% of the prototype's requests — Q22), or with the+            // assets gone. This test rides in `make test-core`, the repo's+            // pre-commit bar, so the host's weather is a known issue rather+            // than a failure — a response that will not decode is not.+            try await withKnownIssue(+                "The on-device model was transiently unavailable or refused the note",+                isIntermittent: true+            ) {+                let result = try await client.extract(Self.source)++                // What the model picks is its business — grounding validates it+                // downstream. What this asserts is that the call round-trips+                // into the structure at all.+                #expect(result.characters.allSatisfy { !$0.name.isEmpty })+            } matching: { issue in+                Self.isTransientModelError(issue.error)+            }+        } when: {+            !SystemLanguageModel.default.isAvailable+        }+    }+}++@Suite("StubCharacterExtractionModelClient")+struct StubCharacterExtractionModelClientTests {+    static let source = FoundationCharacterExtractionModelClientTests.source++    @Test("The canned result answers every call, and every call is recorded")+    func cannedResult() async throws {+        let stub = StubCharacterExtractionModelClient(+            result: ExtractionResult(characters: [ExtractedCharacter(name: "Hanna")]))++        #expect(try await stub.extract(Self.source).characters.map(\.name) == ["Hanna"])+        #expect(try await stub.extract(Self.source).characters.map(\.name) == ["Hanna"])+        #expect(stub.recorder.callCount == 2)+        #expect(stub.recorder.recordedSources.allSatisfy { $0 == Self.source })+    }++    @Test("A script is consumed one entry per call, with the last entry repeating")+    func scriptedResults() async throws {+        let stub = StubCharacterExtractionModelClient(results: [+            .failure(StubCharacterExtractionModelClientError.refused),+            .success(ExtractionResult(characters: [ExtractedCharacter(name: "Grover")])),+        ])++        await #expect(throws: StubCharacterExtractionModelClientError.refused) {+            _ = try await stub.extract(Self.source)+        }+        #expect(try await stub.extract(Self.source).characters.map(\.name) == ["Grover"])+        #expect(try await stub.extract(Self.source).characters.map(\.name) == ["Grover"])+    }++    @Test("A stub with nothing scripted says so rather than answering with an empty cast")+    func noCannedResult() async {+        let stub = StubCharacterExtractionModelClient()++        await #expect(throws: StubCharacterExtractionModelClientError.noCannedResult) {+            _ = try await stub.extract(Self.source)+        }+    }++    @Test("A call is recorded before the delay, so a cancelled call still counts as made")+    func recordedBeforeTheDelay() async {+        let stub = StubCharacterExtractionModelClient(+            result: ExtractionResult(), delay: .seconds(30))+        let task = Task { try await stub.extract(Self.source) }++        while stub.recorder.callCount == 0 {+            await Task.yield()+        }+        task.cancel()++        #expect(stub.recorder.callCount == 1)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/ModelLaneTests.swift Added +371 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ModelLaneTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ModelLaneTests.swiftnew file mode 100644index 0000000..41fd1ad--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/ModelLaneTests.swift@@ -0,0 +1,371 @@+import AsterismCore+import Foundation+import Synchronization+import Testing++@testable import AsterismIntelligence++/// The lane is the single inter-feature arbiter (Req 1.3, Q62/Q77), so its+/// tests are a contention matrix rather than a state machine: who holds, who+/// waits, who pre-empts whom, and what happens when a holder simply goes away.+@Suite("ModelLane")+struct ModelLaneTests {+    // MARK: - Helpers++    /// How long a "this must not complete" assertion waits before concluding it+    /// did not. Long enough that a granted claim would have resumed, short+    /// enough that the suite stays quick.+    static let settleWindow: Duration = .milliseconds(150)++    /// One claim, started and then watched from the outside.+    ///+    /// Its result is polled out of a box rather than awaited: a claim that is+    /// *meant* never to finish is most of what these tests assert, and+    /// `await task.value` on one of those would hang the assertion rather than+    /// fail it.+    /// `Mutex` is non-copyable, so the claim's task cannot capture one directly;+    /// a reference box can be shared instead.+    final class ResultBox<T: Sendable>: Sendable {+        private let value = Mutex<Result<T, any Error>?>(nil)+        var result: Result<T, any Error>? { value.withLock { $0 } }+        func set(_ outcome: Result<T, any Error>) { value.withLock { $0 = outcome } }+    }++    final class Claimant<T: Sendable>: Sendable {+        private let box: ResultBox<T>+        private let task: Task<Void, Never>++        init(_ body: @escaping @Sendable () async throws -> T) {+            let box = ResultBox<T>()+            self.box = box+            task = Task {+                let outcome: Result<T, any Error>+                do {+                    outcome = .success(try await body())+                } catch {+                    outcome = .failure(error)+                }+                box.set(outcome)+            }+        }++        var result: Result<T, any Error>? { box.result }++        func cancel() { task.cancel() }++        /// The claim's result, or nil when it had not finished within `window`.+        func settled(within window: Duration = ModelLaneTests.settleWindow) async+            -> Result<T, any Error>? {+            let deadline = ContinuousClock.now + window+            while ContinuousClock.now < deadline {+                if let result { return result }+                try? await Task.sleep(for: .milliseconds(5))+            }+            return result+        }+    }++    /// Polls until `condition` holds, or fails the test. The lane's queues are+    /// the only ordering the FIFO tests can observe, and a claim reaches them+    /// asynchronously.+    static func waitUntil(+        _ description: String, _ condition: @Sendable () async -> Bool,+        sourceLocation: SourceLocation = #_sourceLocation+    ) async {+        for _ in 0 ..< 400 {+            if await condition() { return }+            try? await Task.sleep(for: .milliseconds(5))+        }+        Issue.record("timed out waiting for \(description)", sourceLocation: sourceLocation)+    }++    /// A recorder for the pre-emption signal, so a test can assert the lane+    /// asked the holder to yield without the holder being a real model call.+    final class YieldSignal: Sendable {+        private let state = Mutex(0)+        var count: Int { state.withLock { $0 } }+        var wasAsked: Bool { count > 0 }+        func handler() -> @Sendable () -> Void {+            { [self] in state.withLock { $0 += 1 } }+        }+    }++    static func granted<T: Sendable>(+        _ claimant: Claimant<T>, sourceLocation: SourceLocation = #_sourceLocation+    ) async throws -> T {+        let result = try #require(await claimant.settled(within: .seconds(2)),+                                  "the claim should have been granted",+                                  sourceLocation: sourceLocation)+        return try result.get()+    }++    // MARK: - One holder at a time++    @Test("The lane grants one holder at a time and the next claim waits for the release")+    func oneHolderAtATime() async throws {+        let lane = ModelLane()+        let first = try await lane.acquire(.interactive)+        #expect(await lane.holderClass == .interactive)++        let second = Claimant { try await lane.acquire(.interactive) }+        await Self.waitUntil("the second claim to queue") { await lane.interactiveWaiting == 1 }+        #expect(await second.settled() == nil)++        await lane.release(first)+        _ = try await Self.granted(second)+        #expect(await lane.holderClass == .interactive)+        #expect(await lane.interactiveWaiting == 0)+    }++    // MARK: - Pre-emption (interactive over background)++    @Test("An interactive claim asks a background holder to yield, then takes the slot")+    func interactivePreemptsBackground() async throws {+        let lane = ModelLane()+        let signal = YieldSignal()+        let background = try await lane.acquire(.background, onPreempt: signal.handler())+        #expect(!signal.wasAsked)++        let interactive = Claimant { try await lane.acquire(.interactive) }+        await Self.waitUntil("the background holder to be asked to yield") { signal.wasAsked }++        // The two-step protocol: the lane asks, it does not seize. Until the+        // holder settles and releases, the slot has not changed hands.+        #expect(await lane.holderClass == .background)+        #expect(await interactive.settled() == nil)++        await lane.release(background)+        _ = try await Self.granted(interactive)+        #expect(await lane.holderClass == .interactive)+        #expect(signal.count == 1, "the holder is asked once, not once per poll")+    }++    @Test("An interactive holder is never asked to yield, whoever claims next",+          arguments: ModelLaneClass.allCases)+    func interactiveIsNeverPreempted(claimant: ModelLaneClass) async throws {+        let lane = ModelLane()+        let signal = YieldSignal()+        let holder = try await lane.acquire(.interactive, onPreempt: signal.handler())++        let claim = Claimant { try await lane.acquire(claimant) }+        await Self.waitUntil("the claim to queue") {+            await lane.interactiveWaiting + lane.backgroundWaiting == 1+        }++        #expect(!signal.wasAsked)+        #expect(await claim.settled() == nil)+        await lane.release(holder)+        _ = try await Self.granted(claim)+    }++    @Test("A background claim never asks a background holder to yield")+    func backgroundNeverPreemptsBackground() async throws {+        let lane = ModelLane()+        let signal = YieldSignal()+        let holder = try await lane.acquire(.background, onPreempt: signal.handler())++        let claim = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the claim to queue") { await lane.backgroundWaiting == 1 }++        #expect(!signal.wasAsked)+        #expect(await claim.settled() == nil)+        await lane.release(holder)+        _ = try await Self.granted(claim)+    }++    // MARK: - Queueing++    @Test("Interactive claims from different features queue FIFO and never pre-empt each other")+    func crossFeatureInteractiveIsFIFO() async throws {+        let lane = ModelLane()+        let holder = try await lane.acquire(.interactive)++        let signalA = YieldSignal()+        let first = Claimant { try await lane.acquire(.interactive, onPreempt: signalA.handler()) }+        await Self.waitUntil("the first claim to queue") { await lane.interactiveWaiting == 1 }+        let second = Claimant { try await lane.acquire(.interactive) }+        await Self.waitUntil("the second claim to queue") { await lane.interactiveWaiting == 2 }++        await lane.release(holder)+        let firstToken = try await Self.granted(first)+        // The one that queued first holds; the one behind it is still waiting,+        // and being interactive bought it no pre-emption of its peer.+        #expect(await second.settled() == nil)+        #expect(!signalA.wasAsked)++        await lane.release(firstToken)+        _ = try await Self.granted(second)+    }++    @Test("A background claim queues behind an interactive one that arrived after it")+    func backgroundQueuesBehindEverything() async throws {+        let lane = ModelLane()+        let holder = try await lane.acquire(.interactive)++        let background = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the background claim to queue") { await lane.backgroundWaiting == 1 }+        let interactive = Claimant { try await lane.acquire(.interactive) }+        await Self.waitUntil("the interactive claim to queue") { await lane.interactiveWaiting == 1 }++        await lane.release(holder)+        let interactiveToken = try await Self.granted(interactive)+        #expect(await background.settled() == nil,+                "background queues behind everything, whenever it arrived")++        await lane.release(interactiveToken)+        let backgroundToken = try await Self.granted(background)+        #expect(backgroundToken.laneClass == .background)+    }++    @Test("Two background sweeps serialise: the second runs only once the first releases")+    func backgroundVersusBackgroundSerialises() async throws {+        let lane = ModelLane()+        let first = try await lane.acquire(.background)+        let second = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the second sweep to queue") { await lane.backgroundWaiting == 1 }++        #expect(await second.settled() == nil)+        await lane.release(first)+        let token = try await Self.granted(second)+        #expect(await lane.holderClass == .background)+        await lane.release(token)+        #expect(await lane.holderClass == nil)+    }++    // MARK: - Never a stranded slot (Q77)++    @Test("A token that is simply dropped releases the slot on deinit")+    func tokenReleasesOnDeinit() async throws {+        let lane = ModelLane()+        // The token dies with this call's frame: a torn-down view or an+        // abandoned pass looks exactly like this.+        func takeAndDrop() async throws {+            _ = try await lane.acquire(.background)+        }+        try await takeAndDrop()++        await Self.waitUntil("the dropped token to free the slot") { await lane.holderClass == nil }+        let next = Claimant { try await lane.acquire(.interactive) }+        _ = try await Self.granted(next)+    }++    @Test("Cancelling the holder's task frees the slot when its frame unwinds")+    func holderTaskCancellationReleases() async throws {+        let lane = ModelLane()+        let holding = Claimant {+            let token = try await lane.acquire(.background)+            #expect(token.laneClass == .background)+            // Held for the lifetime of the work; the cancellation unwinds it.+            try await Task.sleep(for: .seconds(30))+        }+        await Self.waitUntil("the holder to take the slot") { await lane.holderClass == .background }++        holding.cancel()+        await Self.waitUntil("the cancelled holder to free the slot") {+            await lane.holderClass == nil+        }+        let next = Claimant { try await lane.acquire(.interactive) }+        _ = try await Self.granted(next)+    }++    @Test("Cancelling a waiting claim withdraws it and leaves the queue clean")+    func cancelledClaimWithdraws() async throws {+        let lane = ModelLane()+        let holder = try await lane.acquire(.interactive)+        let waiting = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the claim to queue") { await lane.backgroundWaiting == 1 }++        waiting.cancel()+        let result = try #require(await waiting.settled(within: .seconds(2)))+        #expect(throws: CancellationError.self) { try result.get() }+        await Self.waitUntil("the withdrawn claim to leave the queue") {+            await lane.backgroundWaiting == 0+        }++        // Releasing now must hand the slot to nobody rather than to a claim+        // that has gone away — a grant to a withdrawn waiter strands the slot,+        // which is what Q77 exists to prevent.+        await lane.release(holder)+        #expect(await lane.holderClass == nil)+        let next = Claimant { try await lane.acquire(.background) }+        _ = try await Self.granted(next)+    }++    @Test("Releasing a token twice, or a token the lane no longer holds, changes nothing")+    func releaseIsIdempotent() async throws {+        let lane = ModelLane()+        let first = try await lane.acquire(.interactive)+        await lane.release(first)+        await lane.release(first)+        #expect(await lane.holderClass == nil)++        let second = try await lane.acquire(.background)+        // The stale token must not evict the holder that replaced it.+        await lane.release(first)+        #expect(await lane.holderClass == .background)+        await lane.release(second)+        #expect(await lane.holderClass == nil)+    }++    // MARK: - A lane wait is not a settlement (Req 1.3, Q68)++    @Test("Waiting for the lane charges no budget and records no attempt")+    func laneWaitIsNotASettlement() async throws {+        let lane = ModelLane()+        let holder = try await lane.acquire(.interactive)++        // The rule-suggestion ledger stands in for any feature's bookkeeping:+        // what the lane must not touch is exactly this.+        var ledger = RuleSuggestionLedger()+        ledger.beginSweep()+        let waiting = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the sweep's claim to queue") { await lane.backgroundWaiting == 1 }++        #expect(ledger.budgetSpent == .zero)+        #expect(!ledger.isAttempted("a.example"))+        #expect(ledger.inFlight == nil)++        await lane.release(holder)+        let token = try await Self.granted(waiting)+        // Still nothing: the grant is not a settlement either.+        #expect(ledger.budgetSpent == .zero)+        #expect(!ledger.isAttempted("a.example"))+        await lane.release(token)+    }++    // MARK: - Rule-suggestion adoption regressions++    @Test("Feature-internal pre-emption swaps inside the held slot, without a handover")+    func featureInternalSwapKeepsTheSlot() async throws {+        let lane = ModelLane()+        let signal = YieldSignal()+        // Rule suggestion holds the lane for its own on-open attempt.+        let held = try await lane.acquire(.interactive, onPreempt: signal.handler())++        // Its ledger still arbitrates .request over .open by itself+        // (RuleSuggestionLedger.swift:251-269) — the lane is not consulted, and+        // a queued extraction claim therefore cannot steal the slot mid-swap.+        var ledger = RuleSuggestionLedger()+        let fingerprint = CorpusFingerprint(siteMode: .untaught, entryCount: 3, latestCaptureAt: nil)+        #expect(ledger.start(hostname: "a.example", origin: .open, fingerprint: fingerprint,+                            environment: ModelWorkEnvironment()) == .start)+        let queued = Claimant { try await lane.acquire(.background) }+        await Self.waitUntil("the extraction sweep to queue") { await lane.backgroundWaiting == 1 }++        #expect(ledger.start(hostname: "b.example", origin: .request, fingerprint: fingerprint,+                            environment: ModelWorkEnvironment()) == .preempt(hostname: "a.example"))+        #expect(await lane.holderClass == .interactive)+        #expect(!signal.wasAsked)+        #expect(await queued.settled() == nil,+                "the background claim must not be granted by a feature-internal swap")++        await lane.release(held)+        _ = try await Self.granted(queued)+    }++    @Test("The lane class of an origin: reader work is interactive, the sweep is background",+          arguments: zip(Origin.allCases, [ModelLaneClass.background, .interactive, .interactive]))+    func laneClassOfOrigin(origin: Origin, expected: ModelLaneClass) {+        #expect(ModelLaneClass(origin) == expected)+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift Modified +9 / -9
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swiftindex bc4cb66..e2bc79e 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/RuleSuggestionLedgerTests.swift@@ -8,7 +8,7 @@ import Testing struct RuleSuggestionLedgerTests {     // MARK: - Fixtures -    static let foreground = RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false,+    static let foreground = ModelWorkEnvironment(isActive: true, isLowPowerMode: false,                                                       thermalState: .nominal)      static func suggestion(_ hostname: String) -> RuleSuggestion {@@ -341,13 +341,13 @@ struct RuleSuggestionLedgerTests {     @Test("The background sweep is refused in Low Power Mode, on heat, and when inactive",           arguments: zip(               [-                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: true, thermalState: .nominal),-                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false, thermalState: .serious),-                  RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false, thermalState: .critical),-                  RuleSuggestionEnvironment(isActive: false, isLowPowerMode: false, thermalState: .nominal),+                  ModelWorkEnvironment(isActive: true, isLowPowerMode: true, thermalState: .nominal),+                  ModelWorkEnvironment(isActive: true, isLowPowerMode: false, thermalState: .serious),+                  ModelWorkEnvironment(isActive: true, isLowPowerMode: false, thermalState: .critical),+                  ModelWorkEnvironment(isActive: false, isLowPowerMode: false, thermalState: .nominal),               ],               [RefusalReason.lowPower, .thermallyConstrained, .thermallyConstrained, .notActive]))-    func backgroundGates(environment: RuleSuggestionEnvironment, reason: RefusalReason) {+    func backgroundGates(environment: ModelWorkEnvironment, reason: RefusalReason) {         var ledger = RuleSuggestionLedger()          let outcome = ledger.start(hostname: "a.example", origin: .background,@@ -360,7 +360,7 @@ struct RuleSuggestionLedgerTests {     @Test("A fair thermal state does not stop the sweep")     func fairThermalIsFine() {         var ledger = RuleSuggestionLedger()-        let environment = RuleSuggestionEnvironment(isActive: true, isLowPowerMode: false,+        let environment = ModelWorkEnvironment(isActive: true, isLowPowerMode: false,                                                     thermalState: .fair)          let outcome = ledger.start(hostname: "a.example", origin: .background,@@ -373,7 +373,7 @@ struct RuleSuggestionLedgerTests {           arguments: [Origin.open, .request])     func gatesDoNotApplyToReaderAttempts(origin: Origin) {         var ledger = RuleSuggestionLedger()-        let environment = RuleSuggestionEnvironment(isActive: false, isLowPowerMode: true,+        let environment = ModelWorkEnvironment(isActive: false, isLowPowerMode: true,                                                     thermalState: .critical)          let outcome = ledger.start(hostname: "a.example", origin: origin,@@ -732,7 +732,7 @@ struct RuleSuggestionLedgerTests {     @Test("A refused start records nothing")     func refusedStartRecordsNothing() {         var ledger = RuleSuggestionLedger()-        let environment = RuleSuggestionEnvironment(isActive: false, isLowPowerMode: false,+        let environment = ModelWorkEnvironment(isActive: false, isLowPowerMode: false,                                                     thermalState: .nominal)          _ = ledger.start(hostname: "a.example", origin: .background,
docs/agent-notes/schema-migration.md Modified +80 / -51
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex 7a53490..765558f 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,27 +1,32 @@ # Schema migration -Schema **V6** is live (since `specs/configurable-work-types/`), with **V5**-frozen beside it as the `from` side of one lightweight stage. The app opens-`openForApp` and the share extension `openForExtension` — one opener per role,-both over the same schema and the same file layout (`retire-migration-chain`'s-Req 4). Read the "Current state" section; everything under "History" is-background for the *next* schema bump and describes states that no longer exist.+Schema **V7** is live (since `specs/character-extraction/`), with **V5** and+**V6** frozen beside it as the `from` sides of two lightweight stages. The app+opens `openForApp` and the share extension `openForExtension` — one opener per+role, both over the same schema and the same file layout+(`retire-migration-chain`'s Req 4). Read the "Current state" section; everything+under "History" is background for the *next* schema bump and describes states+that no longer exist.  ## Current state  - **Every `@Model` is nested; there are zero top-level `@Model` types.** The live-  classes live in `extension AsterismSchemaV6 { @Model final class Entry … }`+  classes live in `extension AsterismSchemaV7 { @Model final class Entry … }`   (`Models.swift`) and are reached by top-level typealiases-  (`typealias Entry = AsterismSchemaV6.Entry`). `AsterismSchemaV5.swift` holds the-  frozen snapshot — stored columns only, `public init() {}`, no accessors — in the-  shape the V4 snapshot had before it was deleted. The nesting is what makes that-  snapshot legal; keep it.-- **`AsterismV6MigrationPlan` = `[V5, V6]`, one `.lightweight(V5 → V6)` stage.**-  V6 adds `Work.workTypeID` and the `WorkTypeEntity` table and changes nothing-  that exists, so `ModelContainer.init` runs the whole conversion and no data pass-  accompanies it. `V5RecordedStoreTests` measures it end to end: a store seeded-  through the frozen snapshot (recorded `5.0.0`) opens, is left recorded at-  `6.0.0`, and reads every field back.+  (`typealias Entry = AsterismSchemaV7.Entry`). `AsterismSchemaV5.swift` and+  `AsterismSchemaV6.swift` hold the frozen snapshots — stored columns only,+  `public init() {}`, no accessors — in the shape the V4 snapshot had before it+  was deleted. The nesting is what makes those snapshots legal; keep it.+- **`AsterismV7MigrationPlan` = `[V5, V6, V7]`, two lightweight stages**+  (V5 → V6, V6 → V7). V6 added `Work.workTypeID` and the `WorkTypeEntity` table;+  V7 adds the `Character` and `CharacterSuppression` tables and the two optional+  coverage-fingerprint columns, and changes nothing that exists — so+  `ModelContainer.init` runs both conversions and no data pass accompanies+  either. The V5 stage is kept deliberately (Q80 of `character-extraction`):+  retiring it would carry `retire-migration-chain` Decision 6's population+  precondition and buys nothing, and V5-seeded fixtures keep opening.+  `V5RecordedStoreTests` measures the chain end to end: a store seeded through+  the frozen V5 snapshot (recorded `5.0.0`) opens and reads every field back. - **Declaring a stage stops the implicit conversion of stores the plan does not   name.** Under the previous `[V5]`/`stages: []` plan, a `4.0.0` store opened   directly through `openContainer` fell through to Core Data's **inferred@@ -57,24 +62,30 @@ background for the *next* schema bump and describes states that no longer exist.   sidecar and completion pass (`V4Migration.buildSidecar` / `runCompletionPass`)   are deleted; the sidecar *filename* survives because the classifier reads its   presence to refuse an open over a vanished store (Q19).-- **The readiness marker holds `"6"`** — the only version the extension opens-  (Q14). An *empty* store is marked ready at birth, already in the state the-  relationship pass produces (Q26). The app opens three generations, and they are-  three `BootstrapState` cases rather than degrees of one:-  - `"4"` (`.markerLaggingV4`) — the relationship pass has not run. It runs, then-    the marker is republished at `"6"`. The marker tracks the *data pass*, not the-    schema version (Decision 4 of `retire-migration-chain`).-  - `"5"` (`.markerLaggingV5`) — the pass has run and only the marker predates-    `configurable-work-types`. Nothing is owed but the republication: the V5 → V6-    step is the `.lightweight` stage `ModelContainer.init` performs. This is the-    window between the app being updated and first launched, and the extension-    declines throughout it (Req 8.7 of that spec).-  - `"6"` (`.ready`) — certified.+- **The readiness marker holds `"7"`** (`extensionOpenableMarkerVersion`) — the+  only version the extension opens (Q14). An *empty* store is marked ready at+  birth, already in the state the relationship pass produces (Q26). The app opens+  **four** generations (`appOpenableMarkerVersions`, `LibraryRepository+Bootstrap.swift`),+  and they are four `BootstrapState` cases rather than degrees of one:+  - `"4"` (`markerVersionAwaitingRelationshipPass`, `.markerLaggingV4`) — the+    relationship pass has not run. It runs, then the marker is republished at+    `"7"`. The marker tracks the *data pass*, not the schema version (Decision 4+    of `retire-migration-chain`).+  - `"5"` (`markerVersionAwaitingRepublication`, `.markerLaggingV5`) — the pass+    has run and only the marker predates `configurable-work-types`.+  - `"6"` (`markerVersionAwaitingCharacterRepublication`, `.markerLaggingV6`) —+    the pass has run and only the marker predates `character-extraction`.+  - `"5"` and `"6"` owe nothing but the republication: the V5 → V6 and V6 → V7+    steps are the `.lightweight` stages `ModelContainer.init` performs. Each is+    the window between the app being updated and first launched, and the+    extension declines throughout it (Req 8.7 of `configurable-work-types`).+  - `"7"` (`.ready`) — certified.    `runPassAndCertify` therefore takes two flags, `sitePass` and `publishMarker`,-  because those three states need three combinations (Q37). The writer is-  `publishReadiness`, deliberately unversioned: it always writes the current-  generation, and the digit has moved twice. A nonempty unmarked store fails the+  because those states need three combinations (Q37) — `markerLaggingV5` and+  `markerLaggingV6` share one arm, because they owe the same thing and only the+  digit differs. The writer is `publishReadiness`, deliberately unversioned: it+  always writes the current generation, and the digit has moved three times. A nonempty unmarked store fails the   open naming the state; an empty one is marked and opened — where "empty" means   `LibraryRecordCounts.holdsNoReaderRecords`, which excludes the seeded work-type   rows the app writes itself.@@ -93,12 +104,16 @@ background for the *next* schema bump and describes states that no longer exist.   `Site.isWorkOnlyTitleRule` is true when the active pattern is `.wholeTitle`. - **Capability gate is `.m4`** (`AsterismCapabilities.current`). `BackupV4Codec`   carries `"m4"`.-- **Backup is 4/4 only.** `planV4` / `BackupV4Exporter` / the V4 confirm-import-  commit path. The `2/2` and `3/3` import paths, both mappers, and their codecs-  were **deleted** — recovering a pre-M3.5 archive means checking out a build-  that still carries them. `LegacyV2DateFormatter` and `DuplicateJSONKeyValidator`-  survived that deletion in `BackupJSONCodecSupport.swift`; the live V4 codec uses-  both.+- **Backup writes 6/7 and reads 6/7, 5/6 and 4/4.** `BackupV6Exporter` is the+  only exporter the app offers; `planFromV6Archive` / `planFromV5Archive` /+  `planFromV4Archive` all still import, so an older archive is still a recovery+  path. The archive format number is not the schema number: 5/6 is format 5 over+  schema 6 (`configurable-work-types` Q8) and 6/7 is format 6 over schema 7+  (`character-extraction` Q63). The `2/2` and `3/3` import paths, both mappers,+  and their codecs were **deleted** — recovering a pre-M3.5 archive means+  checking out a build that still carries them. `LegacyV2DateFormatter` and+  `DuplicateJSONKeyValidator` survived that deletion in+  `BackupJSONCodecSupport.swift`; the live codecs use both. - **`AsterismSchemaV2` is gone, and so is the second file layout.** It was never   the four-model schema T-2113 described — `Schema` cascades through   `Site.urlRules`, so it always resolved to the same five entities (Q20). What@@ -114,26 +129,28 @@ background for the *next* schema bump and describes states that no longer exist.   unopenable for no user value (Q7, Q13). `FrozenLibraryPathTests` fails if one   moves. -## Adding a schema version (V7 and later)+## Adding a schema version (V8 and later) -V5 → V6 is the worked example: `AsterismSchemaV5.swift` (the frozen snapshot),-`AsterismSchemaV6.swift` (live schema plus the plan), and the two suites that-measure the conversion (`V5RecordedStoreTests`) and the refusal of anything older-(`V4RecordedStoreTests`). What a new version has to touch:+V6 → V7 is the freshest worked example: `AsterismSchemaV6.swift` (the snapshot+frozen by `character-extraction`), `AsterismSchemaV7.swift` (live schema plus the+plan), and the suites that measure the conversion (`V5RecordedStoreTests` walks+the whole chain) and the refusal of anything older (`V4RecordedStoreTests`). What+a new version has to touch:  | Step | Where | |---|---|-| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV6` proper — stored columns only, `public init() {}` — and add `AsterismSchemaV7` with the new models; every entity nested, zero top-level `@Model` |-| Add the stage | `AsterismV6MigrationPlan`'s successor: `.lightweight(fromVersion: V6, toVersion: V7)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |-| Extend the accepted markers | `appOpenableMarkerVersions` and the generation constants beside it — `markerVersionAwaitingRelationshipPass` (`"4"`), `markerVersionAwaitingRepublication` (`"5"`), `extensionOpenableMarkerVersion` (`"6"`, what `publishReadiness` writes) — in `LibraryRepository+Bootstrap.swift`, near `validateMarkerContent`. **Both roles.** Every generation ever published stays in the set: ship a new one without extending it and every device on the old marker fails closed |+| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV7` proper — stored columns only, `public init() {}` — and add `AsterismSchemaV8` with the new models; every entity nested, zero top-level `@Model` |+| Add the stage | `AsterismV7MigrationPlan`'s successor: `.lightweight(fromVersion: V7, toVersion: V8)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |+| Extend the accepted markers | `appOpenableMarkerVersions` and the generation constants beside it — `markerVersionAwaitingRelationshipPass` (`"4"`), `markerVersionAwaitingRepublication` (`"5"`), `markerVersionAwaitingCharacterRepublication` (`"6"`), `extensionOpenableMarkerVersion` (`"7"`, what `publishReadiness` writes) — in `LibraryRepository+Bootstrap.swift`, near `validateMarkerContent`. **Both roles.** Every generation ever published stays in the set: ship a new one without extending it and every device on the old marker fails closed | | Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness; a new marker generation needs a case there and an action beside it, not a guard inside the open |-| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the pass commits — never before |+| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the pass commits — never before. A generation that owes only the republication joins the existing `.markerLaggingV5, .markerLaggingV6` arm rather than growing a third | | Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently |+| Extend the archive, if the schema is reader data | A new table the reader owns needs an archive generation too — see the 6/7 arms — or a backup silently stops round-tripping it |  The marker-lagging paths are the worked examples — `"4"` for a generation that-owes a data pass, `"5"` for one that owes only the marker — and they stay live-and tested precisely so this is copyable rather than reconstructed from git-history.+owes a data pass, `"5"` and `"6"` for ones that owe only the marker — and they+stay live and tested precisely so this is copyable rather than reconstructed from+git history. `specs/relational-references/` is the full worked spec for a relational bump.  **Two things are harder now than they were for V3 → V5.**@@ -150,6 +167,18 @@ precondition in `specs/retire-migration-chain/` Decision 6, not a formality.  ## History — lessons for the next schema bump +### The marker digit has moved three times, and every digit stays openable++`"4"` (the relationship pass, `retire-migration-chain`) → `"5"`+(`configurable-work-types`) → `"6"` (`character-extraction`) → `"7"`, which is+what `publishReadiness` writes today. Each bump superseded a statement that had+read as permanent: the note said "the readiness marker holds `"5"`", then `"6"`,+and each time the *old* digit had to stay in `appOpenableMarkerVersions` rather+than be replaced. That is the lesson, not the digits: a device that has not+launched the new build yet is on the old marker, and the set is what keeps it+openable. `V6` was likewise "the live schema" and the plan was `[V5, V6]`; both+statements were true and both moved on schedule.+ ### Nesting every entity is what makes an in-module snapshot possible  An early attempt to freeze V3 as nested snapshots *while the live classes stayed
docs/agent-notes/testing.md Modified +22 / -0
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 2c90e39..bb54189 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -11,6 +11,28 @@ Before claiming "no new compiler warnings", run the relevant build unwrapped and grep it: `make build-ios 2>&1 | grep -E "warning:|⚠️"` for the app target, `swift build 2>&1 | grep warning:` for the package. +## Xcode can miss an AsterismCore edit and report it as an app-target error++Editing a file under `Packages/AsterismCore/Sources/` and then running+`make test-quick` / `make build-ios` sometimes compiles the **app** target+against a stale `AsterismCore.swiftmodule`. The symptom is misleading: the+errors are all in the app target and say the new API does not exist —+`value of type 'any LibraryProviding' has no member 'characterExtractionCandidates'`+— while `swift build --package-path Packages/AsterismCore` succeeds on the same+source. Check the module's timestamp:++    stat -f "%Sm %N" DerivedData/Build/Products/Development-iphonesimulator/AsterismCore.swiftmodule/*.swiftmodule++If it predates your edit, the package was not rebuilt. `touch`ing the source+does **not** fix it. Delete the package's build products and rerun:++    rm -rf DerivedData/Build/Intermediates.noindex/AsterismCore.build \+           DerivedData/Build/Products/Development-iphonesimulator/AsterismCore.swiftmodule++Seen repeatedly during `character-extraction` (2026-08-21), in a git worktree+with its own `./DerivedData`. Before concluding that a protocol edit "did not+take", check the timestamp — the source is almost always fine.+ ## Simulator "Application failed preflight checks" flake  `make test-ui` / `make test-quick` intermittently fail before any test runs with
specs/OVERVIEW.md Modified +26 / -0
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 93e19e9..6d3d3ea 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -22,6 +22,7 @@ | [Pending-Capture Queue](#pending-capture-queue) | 2026-08-16 | Done — all 20 tasks implemented 2026-08-17; two device checks (protection class before first unlock, share-flow fsync latency) remain open in `prerequisites.md` | v2 plan item 2 (T-2217). The share extension writes every share to a durable record in the App Group container **before** opening the library, and removes it only once the capture commits or the reader cancels; anything failing in between leaves the record for the app to drain on its next activation. No schema, migration or archive change. The directory layout is the queue state (Decision 7) — one file per record, every transition an atomic rename. Reverses two earlier calls after review: a busy library no longer retries (Decision 3, whose original premise about `bootstrapLockTimeout` was wrong), and the queue bounds bytes rather than a count of 100 (Decision 4). | | [Stats Page](#stats-page) | 2026-08-16 | Done — all 16 tasks implemented; Reqs 7.1, 7.2 and 7.5's visual half await the reader's own device check (`prerequisites.md`) | A third tab (T-2192) with two lifetime totals, a bar graph of reading activity over a selected period, and a per-work breakdown of a selected day. Entirely app-layer (Decision 5): notes, dates and work attribution come from `recentPresentation.allRows`, the works total from `worksSnapshot` — `AsterismCore` is untouched. The graph counts **first captures**, so re-reads are deliberately invisible (Decision 1). Supersedes in part `polish-and-export` Reqs 9.3 and 11.2, design §5, and style guide §7/§8. | | [Rule Suggestion](#rule-suggestion) | 2026-08-17 | Done | v2 plan item 3 (T-2156). The on-device Foundation Model proposes a title rule and URL rule for an untaught hostname from its captures; the composed teaching editor opens pre-filled with a "Suggested" marker and the reader saves as normal. Suggestions are precomputed in the background, verified against every capture on the hostname before they are shown, never written without a save, and their absence — model unavailable, verification failed, not finished — leaves the editor exactly as today. First `FoundationModels` use in the tree; adds an `AsterismIntelligence` package target the extension never links. No schema, migration or archive change. |+| [Character Extraction](#character-extraction) | 2026-08-19 | Done | v2 plan item 4 (T-2229). The on-device model reads a work's notes and proposes characters — each fact a verbatim quote citing its source — held until the reader accepts them in a per-candidate, per-fact review; accepted characters are fully reader-editable (and combinable) and live in a new `Character` entity. New schema V7, new archive generation 6/7, CloudKit-synced with the full torn/duplicate machinery. Prototype over a real archive gated the design (Decision 3) and set the schema: aliases yes, confidence no. |  --- @@ -362,3 +363,28 @@ v2 plan item 3 (T-2156). The on-device model proposes, for one hostname, the wor - [tasks.md](rule-suggestion/tasks.md) - [prerequisites.md](rule-suggestion/prerequisites.md) - [implementation.md](rule-suggestion/implementation.md)++---++## Character Extraction++**Created:** 2026-08-19 · **Status:** Done — implemented end to end (all 27 tasks; decision log Q1–Q112 with five ADRs, Q99–Q112 recording post-implementation review verdicts). User-side prerequisites remain open in [prerequisites.md](character-extraction/prerequisites.md), notably the Q86 CloudKit dev-schema publication run and a fresh Personal backup.++v2 plan item 4 (T-2229). A background sweep (plus a manual per-work trigger) runs the on-device model over a work's notes, one source per request; grounded proposals — every fact a verbatim quote from the note it cites — are held until the reader accepts them candidate-by-candidate, fact-by-fact. Accepted characters are reader data in full: editable, combinable, deletable, synced, archived, torn-and-duplicate-handled like Entries and Works.++**Postures worth knowing before reading the spec:**++- **The prototype defined the schema, not a guess** (Decision 3): the spike over a real export produced real casts with perfect greedy repeatability; `aliases` is a field because the model splits the same character across names, and there is **no confidence field** because greedy output carries nothing to fill one.+- **Grounding is mechanical**: a fact's evidence span must appear verbatim in its cited source and a candidate's name in the corpus, checked host-side; the citation is which source the system sent, never model-emitted (Q14/Q15).+- **Characters are reader-authored** (Decision 1): full torn machinery, archive refusal while torn, UUID-only duplicate sets — `.merge` is structurally unreachable (Q76); combining is the reader's explicit act (Decision 4).+- **Coverage is per source revision, suppression is durable and synced, held proposals are neither** (Q16/Q18/Q30): decisions are the only reader data the pipeline writes; everything else re-derives.+- **One model lane app-wide** (Q62/Q77): a new `ModelLane` actor arbitrates between this and rule suggestion — interactive over background, holder tokens, no stranded slot.+- **New archive generation 6/7 and schema V7**; the migration plan stays `[V5, V6, V7]` (Q80), and dangling citations are tolerated on the `workTypeID` precedent (Decision 2).++- [requirements.md](character-extraction/requirements.md)+- [design.md](character-extraction/design.md)+- [decision_log.md](character-extraction/decision_log.md)+- [tasks.md](character-extraction/tasks.md)+- [prerequisites.md](character-extraction/prerequisites.md)+- [implementation.md](character-extraction/implementation.md)+- [prototype/prototype-findings.md](character-extraction/prototype/prototype-findings.md)
specs/character-extraction/decision_log.md Added +508 / -0
diff --git a/specs/character-extraction/decision_log.md b/specs/character-extraction/decision_log.mdnew file mode 100644index 0000000..83642ee--- /dev/null+++ b/specs/character-extraction/decision_log.md@@ -0,0 +1,508 @@+# Decision Log: Character Extraction++## Quick Decisions++| ID | Date | Decision | Rationale |+|----|------|----------|-----------|+| Q1 | 2026-08-19 | Spec directory named `character-extraction` | Names the capability; the schema is a consequence of it |+| Q2 | 2026-08-19 | Full spec workflow; the prototype extraction pass runs as a host-side spike inside the design phase, before `design.md` is written | The v2 plan makes the prototype load-bearing ("do not design the entity before running that pass"); requirements are about behaviour, not field lists, so they can precede it. If the prototype output is unusable, the spec stops at requirements with a recorded finding |+| Q3 | 2026-08-19 | Extraction runs as a background sweep on app activation, plus a manual per-work trigger | Matches the rule-suggestion sweep pattern the reader already lives with; manual trigger is the recovery path for skipped candidates |+| Q4 | 2026-08-19 | Review list with per-candidate accept/skip | House never-write-without-acceptance rule; `PostTeachingWorkURLModel` is the precedent |+| Q5 | 2026-08-19 | No spoiler concept | The corpus is the reader's own notes; they know everything in it already. Cheaper than the plan's open question assumed |+| Q6 | 2026-08-19 | Facts are discrete, each citing the entry whose note it came from | Provenance is the house style; citations feed item 5's Q&A later |+| Q7 | 2026-08-19 | Later sweeps propose additions only; accepted/edited content is never model-modified | Mirrors per-field provenance discipline: manual values are never clobbered |+| Q8 | 2026-08-19 | A skip (or delete) is remembered; only a manual re-run re-proposes | Avoids nagging; explicit path back exists |+| Q9 | 2026-08-19 | Characters shown on work detail and on entry detail (facts citing that entry) | Reader chose both surfaces; share sheet stays T-1916 |+| Q10 | 2026-08-20 | Generic notes stay in the corpus via a second citation form ("cites the work's generic notes") | Reader chose it over dropping generic notes; entry-detail never shows these facts, work-page navigation covers them |+| Q11 | 2026-08-20 | Discovery is an indicator on the work page; no inbox-banner state | Keeps the inbox's closed set of actionable states; proposals are acted on where they apply |+| Q12 | 2026-08-20 | Review list has per-fact toggles inside an accepted candidate | All-or-nothing is wrong for additional-facts bundles on existing characters |+| Q13 | 2026-08-20 | No off-switch for the sweep | Bounds are the control; rule suggestion shipped without one; manual trigger is the reader's lever |+| Q14 | 2026-08-20 | Every fact carries a verbatim evidence span, substring-checked host-side; candidate names must appear in the corpus | The only mechanical grounding check available; review finding — "traced to an entry" was untestable without it |+| Q15 | 2026-08-20 | A fact cites exactly one source; the citation is which source the system sent, never model-emitted | Unforgeable by construction; matches the RuleProposal house pattern (flat strings, never offsets, minimal schema in the 4,096-token window) |+| Q16 | 2026-08-20 | Coverage is tracked per source revision, not per work; "new" = uncovered revision, not a wording diff | Multi-request works are the normal case, not an edge case; makes additions-only coherent under unstable model output (review findings 5/7) |+| Q17 | 2026-08-20 | Citation means historical origin: an accepted fact keeps its evidence span after the cited note is edited | Retained excerpt settles the edited-note question without invalidating accepted data |+| Q18 | 2026-08-20 | Suppression (skips, unticked facts, deletions) is durable, synced, and archived; held proposals are re-derivable; coverage advances only on decision | Skip memory not syncing violates the reader's expectation on a second device; proposals are derived data, decisions are reader data |+| Q19 | 2026-08-20 | Candidate identity is a case-insensitively normalised name key; accepted characters retain their original key so renames don't break matching | Cheapest deterministic rule; alias-based matching is a prototype question |+| Q20 | 2026-08-20 | Manual pass ignores coverage and suppression; visible outcome; trigger hidden while model unavailable | A silent button contradicts an explicit action; manual is the recovery path for skips so it must bypass them |+| Q21 | 2026-08-20 | No character-merge affordance; duplicate characters are edited or deleted | promoted to Decision 4 |+| Q22 | 2026-08-20 | Guardrail refusals skip the source and continue | Web serials are often violent/sexual; one refused note must not sink the work's whole cast |+| Q23 | 2026-08-20 | Model-generated content and reader notes never appear in release diagnostics logs | Mirrors the rule-suggestion logging convention |+| Q24 | 2026-08-20 | Sweep bounds are named constants with provisional values (2 works/activation, 10 s attempt, 60 s budget); one model request at a time app-wide, shared with rule suggestion | Reviews found "in the manner of the rule-suggestion sweep" untestable; values revisited after the prototype |+| Q25 | 2026-08-20 | Zero-fact (name-only) candidates allowed, with the name span-checked against the corpus | Requiring ≥1 fact pressures fabrication; the name check keeps grounding |+| Q26 | 2026-08-20 | Forward archive incompatibility accepted: pre-feature builds cannot read the new generation | Each generation is already a one-way door (v2 plan); stated in 6.1 rather than discovered |+| Q27 | 2026-08-20 | Contradiction handling is out of scope; facts order by cited entry so contradictions read as history | Notes contradict as stories develop; curating is the reader's job |+| Q28 | 2026-08-20 | Prototype spike runs host-side over an exported archive's notes (Mac/simulator with Apple Intelligence), never against the phone | CLAUDE.md device rule; the real corpus reaches the host via the routine Personal export |+| Q29 | 2026-08-20 | Fact identity and suppression key is the triple (name key, source, evidence span) | Evidence alone bleeds suppression across characters sharing a sentence; (source, evidence) alone does too |+| Q30 | 2026-08-20 | Coverage attributes per model request (one source per request), keyed on revision content fingerprint | Pass-scoped coverage throws away four sources' model time when a fifth changes; fingerprint keying makes note-edit and coverage-invalidation the same event |+| Q31 | 2026-08-20 | Acceptance clears name-key suppression; ticking a fact clears that fact's suppression; skipping a bundle never suppresses the character's name key | Otherwise the documented recovery path (manual re-run, accept) contradicts never-re-propose, and one skipped bundle freezes a character out of enrichment |+| Q32 | 2026-08-20 | Determinism requirement dropped; replaced by the dedup invariant (no pass re-presents decided content) with model-output equivalence measured in the prototype gate, post-grounding pre-dedup, under unchanged corpus and decision state | Byte-identical repeatability is unmeetable; dedup makes divergence reader-invisible, which is what the requirement was reaching for |+| Q33 | 2026-08-20 | Failed/refused sources get per-run attempt memory (no same-run retry), not a durable terminal state | Rule-suggestion ledger precedent; a durable synced record for a condition that changes with model version is unjustified machinery |+| Q34 | 2026-08-20 | Sweep is background-class in the shared model lane (never pre-empts, always pre-emptible); budget is per app run; Low Power / thermal gates apply | A 10 s background attempt must not block rule suggestion's 2 s editor-open window; per-activation budgets renew unboundedly within a run |+| Q35 | 2026-08-20 | An oversized source is skipped and left uncovered, never truncated and marked covered | Truncation silently loses content unrecoverable even by the manual pass; chunking is a design question |+| Q36 | 2026-08-20 | Held proposals are device-local; existing-character matching is evaluated at decision time in a deterministic total order (current name, then retained key, oldest on tie) | Cross-device proposal sync manufactures distinct-UUID duplicates; decision-time matching in a total order keeps two devices attaching the same facts to the same character |+| Q37 | 2026-08-20 | Review-list decisions commit immediately, outside the work page's edit-mode transaction | The queue precedent (post-teaching work URLs) commits per item; entangling with a discardable edit basis is the design fork the reviews flagged |+| Q38 | 2026-08-20 | A bundle is shown alongside the target character's existing facts | Near-duplicate evidence spans across runs would otherwise read as new facts |+| Q39 | 2026-08-20 | Hand-created characters are in scope; their name key derives from the typed name | Full edit/delete without create was an odd asymmetry given duplicates are fixed by hand |+| Q40 | 2026-08-20 | Suppression and coverage are non-authored system records: union convergence, never torn, never block export | CloudKit guarantees concurrent rows for the same key; giving bookkeeping the torn machinery would be absurd |+| Q41 | 2026-08-20 | Name-key normalisation follows the `WorkTypeName.normalize` precedent (trim → NFC → locale-independent case folding) | Identity that syncs must agree across devices and locales; the Turkish-I reasoning is already written down there |+| Q42 | 2026-08-20 | Model request carries one source's text plus the work's display title, nothing else | "What identification requires" was unfalsifiable; enumerate it |+| Q43 | 2026-08-20 | The create-character affordance lives in the work page's edit mode, alongside edit and delete | Reader annotation on 5.3: creation was in scope (Q39) but unplaced; edit mode is where structural work-page changes commit |+| Q44 | 2026-08-20 | Hand-creating a character clears a standing suppression of its derived name key | Mirrors acceptance (Q31); otherwise a re-created character is frozen out of enrichment forever |+| Q45 | 2026-08-20 | A torn work blocks review-list decisions too, not just edit mode | Both paths to "new character on this work" now behave alike; committing into a torn work outside its disclosure flow would deepen the tear |+| Q46 | 2026-08-20 | A hand-created character's name key is minted at creation commit and retained through renames | Mid-edit retyping must not mint keys; retention matches the accepted-character rule and keeps 2.3's matching deterministic across devices |+| Q47 | 2026-08-20 | A name-key suppression blocks new candidates only, never bundles for an existing character | Makes the clear in 2.5 meaningful and keeps an existing character enrichable regardless of suppression history |+| Q48 | 2026-08-20 | Torn gates (work or target character) refuse *acceptance* only, with disclosure; skips and unticks continue | Skips write only union-convergent system records; blocking them would strand 2.7's stale-skip rule; matches the accept/skip asymmetry already set for staleness |+| Q49 | 2026-08-20 | 1.7's suppression limb applies to automatic sweeps only; accepted-fact dedup applies to every pass | Resolves the 1.7/1.11 contradiction and reopens the delete-then-recreate recovery path: the manual pass may propose suppressed facts, and accepting them clears the suppression |+| Q50 | 2026-08-21 | Deleting a character suppresses its retained, current-name, and alias keys | A rename-then-delete would otherwise re-propose the character under the deleted name; after Decision 4, aliases own absorbed names' routing and must die with the character |+| Q51 | 2026-08-20 | 2.3's tie-break is lowest character UUID | "Oldest" by device-set timestamps is not identical across devices; UUIDs are |+| Q52 | 2026-08-20 | Suppression convergence is by reader-action recency, not naive union; mechanism (tombstones/markers) is design | A clear is unrepresentable under set-union — an offline device's stale suppression would resurrect it |+| Q53 | 2026-08-20 | The sweep passes over torn works | Their proposals could not be accepted (Q48) and would die undecided on restart — budget spent for nothing |+| Q54 | 2026-08-20 | Bounds re-provisioned from prototype timings: attempt timeout 30 s, run budget 120 s, works/activation 2, works examined 100; output caps 24 candidates/pass, 12 facts/candidate, 100-char names, 500-char facts and spans | Measured 8.3 s median / 23.5 s max per request on the Mac; the phone is slower still. Device values are a later spike, per the T-2228 precedent |+| Q55 | 2026-08-20 | The prompt asks for named story characters only, with facts about that character | The junk tail (~25%: "everyone", "redevelopment law", "the general") and mis-attributed facts are prompt-shaped; the review flow catches what tightening misses |+| Q56 | 2026-08-20 | `aliases: [String]` on Character, reader-editable, used as additional match keys for bundle routing | Prototype shows the same character under Terawatt/Alex/Terrawatt, Bruce/Batman, Jo/Jo Lupo; exact name-key matching alone would split them |+| Q57 | 2026-08-20 | Facts stored as a Codable blob (`factsData`) on Character, not a Fact entity | `URLRulePattern.definitionData` precedent; a fact edit is a character-level authored change, tearing at character granularity (answers round-1's fact-level sync identity question); entry-detail lookup scans a work's characters, N is small |+| Q58 | 2026-08-20 | `Character.work` is a nullify relationship with an inverse, repointed explicitly in merge and duplicate collapse; deletion cascade is repository-enforced | Matches `Work.entries` exactly; the merge path already repoints entries by fetch-and-assign, characters join the same step (N16) |+| Q59 | 2026-08-20 | Coverage lives as optional derived fingerprint fields: `Entry.characterExtractionFingerprint`, `Work.genericNotesExtractionFingerprint` — excluded from authored content | Fields on existing entities, CloudKit-safe (optional), same class as the seven rule-citation fields; a separate coverage entity would be a join table for a 1:1 fact |+| Q60 | 2026-08-20 | Suppression is a `CharacterSuppression` model: work ref, kind (candidate/fact), name key, source ref + evidence for facts, status (active/cleared), actionAt; convergence keeps the latest actionAt | Q52's reader-action recency needs a comparable timestamp; row deletion as "clear" would resurrect under offline sync; never-torn (`NoAuthoredContent` class) |+| Q61 | 2026-08-20 | Held proposals are in-memory on the coordinator's ledger, like rule suggestion's | Q36 made them device-local; the ledger precedent ("an app run is the whole lifetime") is exactly this contract |+| Q62 | 2026-08-20 | A shared `ModelLane` actor in AsterismIntelligence owns the single in-flight slot with background/interactive classes; each feature keeps its own ledger and budget | Origin arbitration is hard-coded into the rule ledger's `start`; extracting only the slot (not the ledger) is the minimal refactor that satisfies Q34 |+| Q63 | 2026-08-20 | Archive generation 6/7 reuses frozen record types and adds three arrays: characters, suppressions, coverage (entry/work UUID + fingerprint pairs) | Re-freezing the Entry record over one derived field would churn the whole codec; a coverage array keyed by UUID rides beside the frozen records |+| Q64 | 2026-08-20 | Name keys strip a leading English article ("the ") after case folding | "The Crowned One" and "crowned one" were the same character in the prototype; further stripping is speculation |+| Q65 | 2026-08-20 | Coverage: coordinator computes source completeness, repository writes it transactionally with the decision; a source emptied by grounding/filtering counts as produced-none and covers at pass time | Explain-like finding 1: the knowledge and the write live in different layers; crash direction is safe (re-derive → filter → cover) |+| Q66 | 2026-08-20 | `commitCharacterDecision` re-verifies the bundle's displayed target; a re-routed match refuses as stale | Facts must never commit to a character the reader wasn't shown |+| Q67 | 2026-08-20 | Alias keys are a third match tier (current name, retained key, alias), same normalisation, lowest UUID within a tier | Keeps Q36's cross-device determinism with aliases in play |+| Q68 | 2026-08-20 | Interactive lane claims queue FIFO; the manual pass holds the lane per request, releasing between sources | A minutes-long manual pass must not starve rule suggestion's editor request |+| Q69 | 2026-08-20 | A pre-empted background attempt charges its model phase but is not marked attempted and stays uncovered | The rule ledger's `.cancelled` rule; the source did nothing wrong |+| Q70 | 2026-08-20 | Sync-orphaned characters/suppressions (work not yet arrived) are inert and tolerated, visible when the work lands | Req 6.7; the `workTypeID` tolerated-state class |+| Q71 | 2026-08-20 | Sweep recency is newest `max(modifiedAt, lastSharedAt)` among noted entries | A note edit bumps only `modifiedAt` and is exactly the event that uncovers a revision |+| Q72 | 2026-08-20 | Suppression rows carry an explicit `sourceKindRaw`; `sourceEntryID` only for entry kind | nil-as-genericNotes would be indistinguishable from a malformed row |+| Q73 | 2026-08-20 | Edit-mode character changes commit as one repository call against per-character bases; any conflict refuses the whole character step, editor stays open naming the character | Guard-and-stay matches `commitEditing()`'s existing halves; partial character commits would be unreviewable |+| Q74 | 2026-08-20 | An accepted fact's quote is immutable; statement and note are the editable texts | Editing the quote changes the identity triple and reopens dedup; delete is the remedy |+| Q75 | 2026-08-21 | Canonical facts encoding for authored-content comparison: sorted-keys JSON, facts ordered by (source, quote, statement) | Two devices holding the same facts must byte-match or characters false-tear; the statement component keeps the order total once edited-apart copies (Q94) can share a triple |+| Q76 | 2026-08-20 | Character duplicate sets bucket by application UUID only; `.merge` unreachable; divergent same-UUID rows tear to the sheet, chosen-only | Content-key bucketing (the entry/work shape) would auto-collapse distinct-UUID characters, which Req 6.4 forbids |+| Q77 | 2026-08-20 | The lane is the single inter-feature arbiter, with holder tokens releasing on cancel/deinit; feature-internal pre-emption happens inside a held slot without handoff | Two arbiters with two in-flight notions was the design's biggest hole; a stranded slot would kill both sweeps for the run |+| Q78 | 2026-08-20 | One locked repository read supplies the filter's whole input (fingerprints, coverage, accepted fact keys, active suppressions); the exporter enumerates all characters, not works→children | The dedup filter had no input surface; child-of-work enumeration silently drops sync orphans from backups |+| Q79 | 2026-08-20 | Facts are canonicalised to the resolved character's retained key before dedup/suppression/storage, and each `CharacterFact` persists that key | Closes the alias-spelling dedup leak (Terawatt/Terrawatt) without weakening Q29's cross-character protection |+| Q80 | 2026-08-20 | The migration plan stays `[V5, V6, V7]` | Retiring the V5→V6 stage carries retire-migration-chain Decision 6's population precondition and buys nothing; V5-seeded fixtures keep opening |+| Q81 | 2026-08-20 | Imported coverage pairs are self-validating: kept only where the archived fingerprint matches current source text, else dropped | Coverage has no timestamp for value-guarding; fingerprint keying makes the safe rule free |+| Q82 | 2026-08-20 | Suppression writes update the local row in place; sync duplicates read via latest `actionAt`, tie-broken cleared-wins then lowest UUID | Unbounded row accretion and the Q52 resurrection both die here; a skew-lost clear just means clearing again |+| Q83 | 2026-08-20 | One candidate per name key per pass, aggregating facts across sources; stale when any cited revision changed | Per-source candidates would show the same character N times in one review list |+| Q84 | 2026-08-20 | Candidate recency includes `Work.modifiedAt` where generic notes are non-empty | A generic-notes-only edit moves no entry timestamp and would never surface the work |+| Q85 | 2026-08-20 | Citation repointing rewrites `factsData` canonically on every row of the character group in the collapse/merge transaction | Rewriting one row of a group changes its authored bytes and would false-tear it |+| Q86 | 2026-08-20 | Post-migration, run the Development configuration so CloudKit publishes the new record types before a second device syncs | schema-migration.md flags container-shared schema publication as the newly hard step |+| Q87 | 2026-08-20 | Torn characters disclose through the existing duplicate-resolution surfaces (new `.character` arm) and the work page's attention card | Req 6.5's "as existing torn records are", literally; no new surface |+| Q88 | 2026-08-21 | Fact display order: generic-notes facts, live citations by capture order, dangling citations last, stable by (quote, statement) | Dangling citations have no capture order; the tail must still be deterministic, incl. for edited-apart copies |+| Q89 | 2026-08-21 | Combining is an edit-mode action modelled on the work "Merge into…" precedent; it writes no suppressions | Deletion semantics (Q50) would fight the alias routing that makes the combine useful |+| Q90 | 2026-08-21 | Slash-compound candidate names split into name + proposed aliases | promoted to Decision 5 |+| Q91 | 2026-08-21 | Combine's alias union carries the source's match keys (current, retained — stored as a bare key string where no display form survives — and aliases), deduped against the target's own keys | Union of display strings alone would drop a renamed source's retained key and re-manufacture the duplicate the combine fixed |+| Q92 | 2026-08-21 | A proposed alias is shown on the review row and strikeable before accepting; skip suppresses only the keys displayed at skip time | An un-vetoable silent alias would mis-route a mistaken pairing split forever |+| Q93 | 2026-08-21 | Proposed aliases do not participate in decision-time matching; a split candidate whose alias half names an existing character shows as a new candidate | Keeps 2.3's single-key deterministic order; the accepted duplicate path is combine's job, and it is one tap |+| Q94 | 2026-08-21 | Combine re-keys the source's active fact suppressions to the target; when a moved fact's triple collides with the target's but statements were edited apart, both copies survive | Orphaned source-keyed suppressions would resurrect unticked facts; dropping an edited statement would contradict 3.7's guarantee |+| Q95 | 2026-08-21 | Combine's sync races (delete-vs-remote-edit, reciprocal combines) resolve by last-writer rules and are accepted; 3.7's no-loss guarantee is scoped to the local act | No existing machinery covers delete-vs-edit; mitigation would need tombstone reconciliation the feature cannot justify — recorded under Decision 4's negatives |+| Q96 | 2026-08-21 | A bundle from a split candidate carries its proposed aliases; accepting installs the unstruck ones on the character | Without this the split buys nothing in the common case (the name half already exists); the strike control is the same veto as Q92 |+| Q97 | 2026-08-21 | Combine is staged in the edit session, discardable until commit; staged character operations apply in performed order inside the one edit-step call | Rides 5.3 as the reader approved; Decision 4's "not undoable" negative applies only after commit |+| Q98 | 2026-08-21 | The identity triple is not unique within a character (edited-apart copies share one); a triple suppression covers every copy | Follows from Q94; keeps dedup and deletion semantics single-keyed |+| Q99 | 2026-08-21 | Name-key article stripping repeats until stable, amending Q64's "one leading article" | Idempotence is load-bearing: Q91 stores a bare retained key as an alias and alias matching re-normalises, so a key that moves on re-normalisation stops routing; cost is one pathological name ("The The" keys to "the"). Single implementation lives in AsterismCore's CharacterNameKey — the AsterismIntelligence copy (a stream-parallelism artifact) is removed |+| Q100 | 2026-08-21 | The extraction ledger merges held proposals by name key per work; the per-source settle API stays | Q83's grain is one candidate per name key per pass; per-source appends showed the same character twice and discard removed only the first row |+| Q101 | 2026-08-21 | A manual pass never charges the sweep run budget | Design's error table says "manual unaffected"; charging both meant one manual pass could silently kill the automatic sweep for the rest of the app run |+| Q102 | 2026-08-21 | Torn-character app surfaces (resolution sheet arms, labels) land with tasks 23/24; the workload keeps characters meanwhile | No production path creates a Character row until task 20 wires the coordinator, and tasks 20 and 23/24 land in the same phase, so the empty-sheet window is unreachable in any shipped state |+| Q103 | 2026-08-21 | Work merge resets extraction coverage for every entry of the merged work, per the literal Req 3.4 | Entry repoints change which work's cast a note grounds against even when the note text is unchanged; merges are rare, so the re-pass cost is accepted |+| Q104 | 2026-08-21 | commitCharacterEdits re-verifies the work's tornness inside the transaction, mirroring commitCharacterDecision | A tear can sync in between the editor opening and the commit; Req 5.3's read-only gate must hold at commit time, not only at presentation |+| Q105 | 2026-08-21 | The 4/4 and 5/6 export paths now also refuse torn characters (shared group projection) | Deliberate: an export that silently drops one variant of a torn character is the data loss Req 6.5 exists to prevent, whatever the archive generation |+| Q106 | 2026-08-21 | Sweep eligibility consults attempt memory: a work whose uncovered sources are all attempted this run does not occupy an activation slot | Coverage alone let a work with held-undecided or all-refused sources hog one of the two slots on every activation for the rest of the run; matches the rule-suggestion precedent of filtering attempted hostnames before the prefix |+| Q107 | 2026-08-21 | The manual-pass trigger stays visible on a work with no characters and no held proposals; Req 5.1's "nothing character-related" is amended to exclude the trigger row | Req 1.11's manual pass exists precisely for works the sweep has not covered; hiding the trigger there would make it unreachable where it is most useful |+| Q108 | 2026-08-21 | Within one edit step, a character's basis is verified on first touch only; later operations in the same step trust the transaction's own intermediate state | The basis check exists to catch external change, not the session's own writes; verifying every derived update against the load-time basis made combine-then-edit structurally unable to commit and blamed a phantom concurrent editor |+| Q109 | 2026-08-21 | The review-proposals indicator and sheet are unreachable while the work page is in edit mode | The sheet's completion reload rebuilds drafts and would silently destroy the staged session Q97 promises is discardable only by the reader's explicit act; same gating the manual-pass trigger already had |+| Q110 | 2026-08-21 | A refusal-driven review refresh reconciles held proposals (and applies any re-route target) before re-reading; the sweep checks its stop signal per source, not per work | Without these, Q66's bundle re-presentation and Req 2.7's freshness were decorative — the sheet re-presented byte-identical rows forever — and resignActive could start a fresh 30 s attempt while backgrounding |+| Q111 | 2026-08-21 | Character conflicts surface as typed refusals at the acting surface (edit-step and review-sheet disclosures), not through AppLibraryModel.recordConflict(.character) | Amends the design's audit-table row: the character flows already own a refusal channel with better wording than the generic conflict banner; behaviour (Req 2.8/5.3) is unchanged and tested |+| Q112 | 2026-08-21 | A generic-notes citation renders as an inert label; only entry citations navigate | Amends Req 5.2's letter: the generic notes live on the very page showing the fact, so navigation would go nowhere useful (Q10's rationale) |++---++## Decision 1: Characters are reader-authored data, editable in full++**Date**: 2026-08-19+**Status**: accepted++### Context++Extracted characters could be model-derived artefacts (regenerable, reader can+accept/reject/delete but not edit) or reader data (fully editable, including a+free-text note per character). The choice decides which integrity machinery the+entity needs: authored content participates in duplicate divergence detection,+can be *torn* under CloudKit sync, must be disclosed and human-resolved when+copies diverge, and a torn group makes the backup exporter refuse.++### Decision++Characters are reader-authored: name, facts, and a per-character note are all+editable, and reader edits are never touched by later extraction passes.++### Rationale++The reader chose full editability. It also fits the product's grain: everything+the reader can see in this app is theirs to correct, and a cast list the model+got slightly wrong (a misspelled name, a fact worth rewording) would otherwise+be uncorrectable short of deleting and hoping the model does better next time.++### Alternatives Considered++- **Model-derived only, regenerable**: no torn state, last-writer-wins, archive+  never refuses — rejected because an uncorrectable record contradicts how every+  other record in the app behaves, and the reader explicitly wanted editing.+- **Editable note only, frozen name/facts**: a middle ground — rejected as an+  arbitrary seam; once any field is authored the torn machinery is in play, so+  freezing the rest buys nothing.++### Consequences++**Positive:**+- Characters behave like Entries and Works everywhere: same mental model, same+  duplicate-review surfaces, same archive guarantees.+- Reader edits are durable and survive sync races via the existing+  convergence/disclosure machinery rather than ad-hoc rules.++**Negative:**+- `Character` needs an `AuthoredContent` conformance, torn-group handling, a+  review-duplicate surface, and archive refusal while torn — materially more+  work than a regenerable record.+- The backup exporter gains a new refusal cause the reader can hit.++---++## Decision 2: Dangling citations are tolerated, in the manner of `Work.workTypeID`++**Date**: 2026-08-20+**Status**: accepted++### Context++A fact cites the source its text came from, and sources die: entries are+deleted, and entry duplicate auto-collapse deletes the losing row without+repointing anything. The codebase holds two opposing precedents for a reference+whose target is absent: the backup exporter's citation check+(`requireCitationsResolve`) refuses the whole export, while `Work.workTypeID`+is a deliberately dangling-capable UUID whose absence is documented as "a+tolerated state, not corruption" and which the archive reference validator+declines to check. Fact citations had to land on one side.++### Decision++A fact citation is tolerated when dangling: the fact keeps its text and+evidence span, displays without navigation, exports and imports legally, and is+never surfaced as an integrity error. Entry duplicate collapse repoints+citations to the surviving row.++### Rationale++The fact's value is its text, which the reader accepted; the citation is+provenance, not a structural dependency. Refusing an export because the reader+deleted a cited entry would fail the whole backup over routine curation — and+under CloudKit, "deleted" and "not yet synced" are indistinguishable at export+time, exactly the reason `workTypeID` chose tolerance. Repointing on collapse+keeps the common duplicate case lossless.++### Alternatives Considered++- **Refusing exporter check (the `requireCitationsResolve` pattern)**: strongest+  integrity guarantee — rejected because deletion of a cited entry is a normal+  reader action, not corruption, and a background auto-collapse could brick+  exports with no reader act at all.+- **Cascade-delete facts with their cited entry**: no dangling possible —+  rejected because it silently destroys accepted reader data on entry deletion.++### Consequences++**Positive:**+- Exports never fail because of curation or sync timing.+- Facts survive their sources; the evidence span keeps them meaningful.++**Negative:**+- A dangling citation is invisible to integrity tooling by design; "deleted"+  and "never synced" cannot be distinguished in the UI.+- The new archive reference validator must deliberately exempt the field, and+  that exemption needs a test so it survives refactors.++---++## Review round 1 (2026-08-20)++Design-critic: 16 findings (2 blockers). Peer validation (Kiro + code-verified;+Codex unavailable in this environment) agreed with all 16, raised four+severities, corrected the finding-11 mechanism to the two-precedent choice now+recorded as Decision 2, and added 17 findings. All were resolved into the+requirements rewrite recorded in Q10–Q28, except these, deliberately deferred+to design:++- **Aliases** are a prototype question — they are the identity problem behind+  the name key (Q19), and the v2 plan expects the prototype to prove them out.+- **Fact-level sync identity** (own record vs part of the Character authored+  aggregate) — decides how much of the identity-group machinery Characters+  need. Noted: a Character is never *bare* (always has an authored name), so+  its torn rate is structurally higher than an Entry's.+- **`WorkEditBasis`**: whether characters join the work edit basis (a synced+  character change would invalidate an in-progress work edit) or carry their+  own basis and contract. *Narrowed by Q43/AC 5.3: surface and transaction+  semantics are fixed (edit mode, commit/discard); only basis granularity and+  the sync-invalidation contract remain open.*+- **Prototype pass criterion**: the gate needs a falsifiable definition of+  "usable" before the spike runs (host-side path fixed by Q28). Draft bar: over+  several real works, the majority of proposed characters are real characters+  with span-verified facts, and re-runs over an unchanged corpus do not produce+  disjoint casts.+- **Corpus economics**: at ~3–15 s per model request and one source per+  request, a full pass over a large work takes minutes; the sweep bounds (Q24)+  and prioritisation (1.3) are the requirements-level answer, chunking and+  scheduling are design.++---++## Review round 2 (2026-08-20)++Design-critic on the rewritten document: 18 findings (7 major), all+consistency-level — no blockers, none touching the reader's settled choices.+Peer validation (Kiro + two lenses + code-verified) confirmed all 18 with+refinements (request-grain coverage instead of pass-scoped; the three-part+suppression key; background-class scheduling; per-run attempt memory instead of+a durable terminal state; stale-skip still suppresses) and added nine findings+(input bounds, held-proposal sync, thermal gate and per-run budget, commit+timing, near-duplicate spans, system-record convergence, name-key+normalisation, suppression repointing, manual-pass budget exemption). All+resolved into the second rewrite, recorded as Q29–Q42. Still deferred to+design, in addition to round 1's list:++- **Prototype gate equivalence bar**: measure model-output equivalence at+  (name key, source, evidence) granularity post-grounding and pre-dedup, under+  greedy decoding, unchanged corpus *and* unchanged decision state (Q32).+- **Chunking** of an oversized single source, if the prototype shows real notes+  hit the window (Q35 fixes the failure mode; design decides whether to split).++---++## Decision 3: The prototype gate passed; the schema follows its output++**Date**: 2026-08-20+**Status**: accepted++### Context++The v2 plan made a prototype extraction pass over real notes a prerequisite of+designing the `Character` entity (Q2), with the design phase gated on its+output being worth keeping. The spike ran host-side (Q28) over the 2026-08-08+Personal export (178 entries, 59 works): the 6 most note-rich works, one model+request per source, grounding checks per Q14, two full greedy runs, measured at+(name key, source, evidence) granularity per Q32. Harness and full findings:+`specs/character-extraction/prototype/`.++### Decision++The gate passes; implementation proceeds. The entity carries what the output+proved: name, aliases, facts as (statement, verbatim quote, citation), a+reader note — and **no confidence field**.++### Rationale++Every sampled work's main cast came back real, multi-fact, and correctly+cited; both runs were identical (Jaccard 1.00 everywhere), so greedy decoding+delivers the repeatability the dedup story leans on. The failure modes that+did appear are exactly what the approved review flow absorbs: a junk tail of+noun-phrase candidates (per-candidate skip), occasional mis-attributed facts+(per-fact toggles), and alias splits (Terawatt/Alex, Bruce/Batman, Jo/Jo Lupo)+— which is why `aliases` is a field. Confidence is dropped because greedy+structured output yields no usable signal to populate it — inventing one would+be decoration. Guardrail refusals hit 11% of requests ("may contain sensitive+content" on ordinary notes), confirming Q22 as load-bearing, not defensive.+Timing (median 8.3 s, p90 15.7 s, max 23.5 s per request on an M-series Mac)+invalidates the provisional bounds: Q24's 10 s attempt timeout would kill half+of all requests.++### Alternatives Considered++- **Stop at requirements** (the gate's failure arm): not taken — the output+  clears the "worth keeping" bar decisively on real data.+- **Keep a confidence field anyway**: rejected — nothing populates it; a fake+  number invites fake UI.++### Consequences++**Positive:**+- The schema is evidence-backed; aliases and per-fact toggles are justified by+  observed behaviour, not speculation.+- Determinism at the level Q32 needs is proven, not assumed.++**Negative:**+- Bounds must be re-provisioned around ~8–24 s requests (Q54); a 40-note work+  takes several sweep runs or a minutes-long manual pass to cover fully.+- The refusal rate means some sources will simply never contribute facts;+  the reader cannot tell which without diagnostics.++---++## Decision 4: Characters can be combined++**Date**: 2026-08-21+**Status**: accepted — supersedes Q21++### Context++Q21 declined a character-merge affordance on the theory that a duplicate+character is trivially re-creatable, so edit-and-delete would do. The+prototype findings falsified the theory on the reader's own library: the same+character routinely appears under names that never co-occur as one string+(Alex vs Terawatt; Terawatt vs the Terrawatt typo), so both get accepted as+separate characters with separate fact sets, and "delete one and retype the+facts" is real loss, not a trivial re-create. The reader, reading the+findings, asked for the ability to combine characters.++### Decision++An edit-mode combine action, modelled on the work "Merge into…" precedent:+target keeps its identity; the source's name and aliases become aliases of+the target; facts move re-keyed and deduped; notes append under a divider;+the source is deleted without suppression writes, so future proposals under+its name route to the combined character through the alias tier.++### Rationale++The alias mechanism (Q56/Q67) already gives a combined character exactly the+routing the split characters lacked; combine is the one-time act that feeds+it. Writing deletion suppressions here (Q50's rule) would fight that routing+— the point of combining is that the source's name keeps attracting facts,+now to the right record.++### Alternatives Considered++- **Edit-and-delete only (Q21)**: rejected — moving N facts by hand through+  the editor is retyping, and the deleted name's suppression would then block+  nothing useful while the alias tier is what should own the name.+- **Delete the duplicate, run a manual pass, accept the bundle** (repair+  through re-extraction — 1.11 ignores suppression): rejected — it loses the+  duplicate's reader-edited statements and note, costs a minutes-long model+  re-run, and only recovers what the model can still derive.+- **"Accept into existing character…" picker on new-candidate rows**:+  prevents the split up front but adds a chooser for a case combine fixes+  once; deliberately not built now, cheap to add later if combining turns out+  to be frequent.++### Consequences++**Positive:**+- The Alex/Terawatt class of split is fixable in two taps, and stays fixed —+  future extraction enriches the combined character.+- Symmetry with works: the same mental model as "Merge into…".++**Negative:**+- A second entity-merge implementation to keep correct (fact re-keying, alias+  union incl. bare match keys, suppression re-keying, basis verification on+  two rows, torn gates on both).+- An accidental combine is not undoable except by hand-splitting.+- Sync races are accepted, not mitigated (Q95): a combine racing a remote+  edit of the source resolves by last-writer rules, and reciprocal combines+  on two offline devices can delete both rows — the backup is the remedy.++---++## Decision 5: Slash-compound names split into name plus proposed aliases++**Date**: 2026-08-21+**Status**: accepted++### Context++The prototype surfaced candidates named exactly as the notes write them —+"Hanna/Action Girl", "Bruce/Batman", "Grover/Klar" — compound forms whose+halves are one character's names. Accepting them as compound names mints+characters no later proposal will match. But in web-serial and fan-fiction+notes, "A/B" also conventionally denotes a relationship pairing of two+characters, so an automatic split can be wrong in a way that actively+mis-routes one character's facts into another via the alias tier.++### Decision++A candidate named with slash-separated components, each grounding in the+source, is assembled as first-component name plus proposed aliases. Proposed+aliases are displayed and strikeable before acceptance (Q92), never installed+silently; they do not participate in decision-time matching (Q93); a bundle+carries them too (Q96). A non-grounding component cancels the split.++### Rationale++The split turns the model's own alias notation into the alias field the+schema has for exactly this, and it seeds forward routing: later "Action+Girl" proposals reach the character accepted as "Hanna". The pairing risk is+real but bounded by the veto — the reader sees the proposed alias on the row+they are already reviewing and strikes it in one tap.++### Alternatives Considered++- **No split — keep compound names**: safe but useless; the compound key+  matches nothing later, and combine becomes the only remedy for a case the+  data shows is routine.+- **Split with alias participating in matching**: routes the common case+  automatically but needs a two-key extension of 2.3's deterministic order+  and makes a wrong pairing split self-reinforcing — rejected (Q93).++### Consequences++**Positive:**+- The prototype's observed alias forms land as aliases, not junk characters.+- Skip/clear semantics stay per-displayed-row and implementable (no+  cross-row suppression grouping needed).++**Negative:**+- A mistaken pairing split, if the reader accepts without striking, installs+  a wrong alias; per-fact ticks and alias editing are the remedy.+- Struck-vs-unstruck state adds one control to the review row.++---++## Design review round (2026-08-20)++Explain-like self-validation: 10 gaps (coverage-write ownership, decision-time+target verification, alias match tier, lane contention, pre-emption+accounting, Req 6.7 orphans, recency proxy, suppression source encoding, edit+conflict contract, quote immutability) → Q65–Q75. Design-critic: 1 blocker+(character duplicate-set bucketing) + 8 major + 6 minor. Peer validation+(Kiro + two lenses, code-verified) confirmed all, corrected the orphan-export+fix to the optional-and-checked-when-present pattern, found the audit table+missing ~15 app-target consumers, and added 7 majors (filter input surface,+stranded lane slot, generic-notes recency blindness, suppression LWW+tie-break, work-deletion cascade, group-safe citation rewriting, CloudKit+schema publication). All resolved in the design rewrite → Q76–Q88.++---++## Review round 4 (2026-08-21) — delta: combine and the slash-split++Reader-driven scope change from the prototype findings (Decision 4, Decision+5). Delta design-critic: 7 major, 5 minor — retained-key loss, orphaned+suppressions, sync races, mid-hold re-routing, pairing ambiguity, split+skip/clear semantics, two-key matching. Peer validation upheld the substance,+found the both-copies rule broke Q75's canonical total order (fixed: sort adds+statement), the clear-them-all rule unimplementable (narrowed to+same-candidate acceptance), the skip sentence hitting bundles (scoped to+candidates), the alias-delete change landed in one document (propagated,+combine carved out of 3.3), the group fan-out missing from the combine row+(added per the work-merge `:274-276` rule), and two open decisions —+bundle-carried aliases (Q96) and transaction shape (staged in the edit+session, Q97). All resolved as Q91–Q98; Q90 promoted to Decision 5; Decision+4's alternatives rewritten against the real repair path.++---++## Review round 3 (2026-08-20) — delta: manual creation placement++Prompted by the reader's annotation on 5.3 (manual creation, in scope since Q39,+had no placed affordance). Delta design-critic: 2 major, 2 minor. Delta peer+validation upheld the placement (Q43) and key minting (Q46), narrowed the torn+gate (Q48), and surfaced three pre-existing structural contradictions — the+clear-under-union problem (Q52), the delete-then-recreate fact dead end+(resolved by Q49), and 1.7 vs 1.11 on manual-pass suppression (Q49). All+resolved as Q44–Q53. Newly deferred to design:++- **Commit sequencing**: where creation, key minting, and the 2.5+  suppression-clear sit in `commitEditing()`'s existing URL-then-metadata+  sequence, which is not atomic and keeps the editor open on partial failure.+- **Clear-marker mechanism** for Q52 (tombstones vs monotonic markers).++---
specs/character-extraction/design.md Added +354 / -0
diff --git a/specs/character-extraction/design.md b/specs/character-extraction/design.mdnew file mode 100644index 0000000..2899154--- /dev/null+++ b/specs/character-extraction/design.md@@ -0,0 +1,354 @@+# Design: Character Extraction++**Ticket:** T-2229 · Requirements: [requirements.md](requirements.md) · Prototype: [prototype/](prototype/) (Decision 3)++## Overview++A background extraction pipeline proposes characters from a work's notes via+the on-device model; a review surface writes only what the reader accepts into+a new `Character` entity (schema V7, archive generation 6/7). The pipeline+mirrors rule suggestion's shape — coordinator + ledger + client behind a+protocol — sharing one model lane with it.++## Architecture++### Placement++| Piece | Target | Why |+|---|---|---|+| `Character` entity, suppression/coverage models, repository surface, archive 6/7 | `AsterismCore` | Store-adjacent; the extension links this and must never see FoundationModels |+| `@Generable` DTOs, model client, grounding, bounds, ledger, `ModelLane` | `AsterismIntelligence` | The only target importing FoundationModels (rule-suggestion Decision 2) |+| `CharacterExtractionCoordinator`, assembler, review/edit/display views | app target | Mirrors `RuleSuggestion/` |++### The shared model lane (Req 1.3, Q62/Q77)++New `actor ModelLane` in AsterismIntelligence — **the single authority on who+may run model work**. Contract:++- `acquire(class:) async -> LaneToken`. One holder at a time. The token+  releases on explicit `release()`, on holder-task cancellation, and on token+  deinit — a cancelled manual pass or torn-down view can never strand the+  slot.+- Classes: `.interactive` and `.background`. An `.interactive` claim pre-empts+  a `.background` holder (cancel, await settle — the two-step protocol at+  `RuleSuggestionCoordinator.swift:274-302`). Interactive claims from+  *different features* queue FIFO and never pre-empt each other.+- **Feature-internal priority stays feature-internal.** While rule suggestion+  holds the slot, its ledger may still replace its own attempt (`.request`+  over `.open`, `.open` over `.open` — `RuleSuggestionLedger.swift:251-269`);+  the slot does not change hands, so a queued extraction claim cannot steal it+  mid-swap. The feature ledgers keep their bookkeeping; arbitration between+  features belongs to the lane alone.+- `.background` claims queue FIFO behind everything, so the two activation+  sweeps run serially, alternating as each releases per source. **Waiting or+  being refused the lane is not a settlement**: it charges no budget, enters+  no attempted memory, consumes no sweep capacity (budget is model time+  only).+- The manual extraction pass is `.interactive` but acquires **per request**,+  releasing between sources — a rule editor claim waits for at most one+  in-flight source. Consequence, accepted: while a manual pass runs, rule+  suggestion's 2 s auto-apply window can be missed; its suggestion still+  surfaces through the ready-state path. Rule suggestion's behaviour is+  otherwise unchanged.+- A pre-empted background extraction attempt charges its spent model phase+  (every settlement charges) but is **not** marked attempted and stays+  uncovered — the `.cancelled` rule — so it may retry later in the run.++### Extraction pipeline (Req 1)++`CharacterExtractionCoordinator` (`@MainActor @Observable`, app target),+wired in `AppLibraryModel.makeSuggestionCoordinator`'s pattern, started+fire-and-forget beside the rule sweep at `AppLibraryModel.swift:395-397`, with+`reconcile()` called from the same `refreshDiagnosesAndSnapshots` hook as rule+suggestion's (`AppLibraryModel.swift:605`) — that is the invalidation path for+deleted works, deleted characters, and changed corpora (Req 2.8).++Sweep, per activation (Req 1.1–1.3):++1. `library.characterExtractionCandidates(limit:)` — one locked read+   returning, per work: source fingerprints, stored coverage, accepted fact+   keys, and active suppressions (the filter's entire input — Q78's context+   read). Ordering: newest of the noted entries' `max(modifiedAt,+   lastSharedAt)` **and** the work's own `modifiedAt` where generic notes are+   non-empty (a generic-notes edit bumps only the work). Capped at+   `worksExamined` (100), torn works excluded (Q53).+2. Up to `worksPerActivation` (2) works: for each uncovered source, one model+   request through the lane at `.background`, gated on active/Low+   Power/thermal exactly as `RuleSuggestionLedger.start` gates `.background`.+3. Ground → filter (dedup + suppression, against the step-1 context) →+   assemble → hold.++Assembly grain: one candidate (or bundle) per name key per pass, aggregating+that character's grounded facts across the pass's sources. A proposal is stale+when **any** cited revision changed (Req 2.7).++Coverage-write ownership: **source completeness is the coordinator's+knowledge; the write is the repository's transaction.** A source emptied by+grounding and filtering is produced-none and covers at pass time. A source+with shown proposals covers inside `commitCharacterDecision`, which receives+the fingerprints the decision completes. The manual pass advances coverage+identically — covering more is the safe direction, regressing never happens+because fingerprint writes only ever move to current text. A crash between+decisions loses only coverage advance: next sweep re-derives, the filter+empties it, produced-none covers it.++The extraction ledger (AsterismIntelligence, pure struct like+`RuleSuggestionLedger`) tracks held proposals per work, per-run attempted+sources (entered on failure/refusal/timeout — Req 1.8 — but not on+cancellation), budget spent, sweep generation. Nothing persists (Q61);+`memoryWarning()`/`resignActive()` wire from `ContentView` as rule+suggestion's do.++Per-source request (Req 1.4/1.5, Q42): display title + one source text; an+oversized source is skipped, left uncovered, logged (Q35).++### Model I/O and grounding (Req 1.6/1.7)++`@Generable` DTOs exactly as prototyped (`prototype/Sources/main.swift`):+`ExtractionResult` → `ExtractedCharacter` (name, facts) → `ExtractedFact`+(statement, quote) — flat strings, no identifiers, greedy sampling. The prompt+asks for **named story characters only** (Q55). `CharacterExtractionModelClient`+protocol + stub mirror `RuleSuggestionModelClient` + its recorder stub;+availability, refusal taxonomy, and `withKnownIssue` test handling reused+as-is.++Grounding (deterministic, in AsterismIntelligence, shared with the prototype+harness): evidence span verbatim in cited source (case-insensitive, NFC),+candidate name present in a processed source, caps per+`CharacterExtractionBounds` (Q54). Name key: trim → NFC → locale-free case+fold (the `WorkTypeName.normalize` recipe, Q41) → strip one leading "the "+(Q64).++Matching tiers (Req 2.3 + Q67): current-name key, then retained key, then+alias keys (same normalisation), lowest character UUID within a tier.++Slash-compound names (Q90): a candidate named "A/B" (or "A/B/C") whose+components each ground in the source is assembled as name A with the rest as+**proposed aliases** — the prototype's Hanna/Action Girl, Bruce/Batman,+Grover/Klar forms; any non-grounding component leaves the name whole. Because+"A/B" in this corpus can also be a relationship pairing, the proposed alias is+shown on the review row and strikeable before accepting (Q92) — never+installed silently. Proposed aliases do **not** participate in decision-time+matching: the proposal matches on its name half only, so a split candidate+whose alias half names an existing character shows as a new candidate — the+accepted duplicate path (Q93); the combine fixes it. Skipping a split+candidate suppresses the keys displayed at skip time (name + unstruck+aliases); accepting it clears exactly those keys. Hand-creation clears only+its own typed name's key (Q44 unchanged — nothing links suppression rows+after the proposal is gone). A component that does not ground is not split+out: the candidate keeps its whole compound name and stands or falls on the+1.6 checks. When a split candidate's name half matches an existing character,+the resulting **bundle carries its proposed aliases** (Q96): accepting+installs the unstruck ones on the character, with the same strike control.++**Fact identity is keyed to the resolved character** (Q79): when a proposal+routes to an existing character, its facts are canonicalised to that+character's **retained key** before dedup, suppression lookup, and storage —+and every accepted `CharacterFact` persists the key it was accepted under. An+alias spelling of an already-accepted quote therefore dedups instead of+re-proposing (the Terawatt/Terrawatt case). A fact's quote is immutable after+acceptance (Q74); the statement and the character note are the editable texts.++### Data model (schema V7)++Nested in `AsterismSchemaV7`, typealiased from `Models.swift`, CloudKit-safe+(defaulted/optional, no uniques, optional to-many with inverse):++```swift+@Model final class Character {+    var id: UUID = UUID()+    var name: String = ""+    var nameKey: String = ""          // retained; minted at accept/create commit (Q19/Q46)+    var aliases: [String] = []        // reader-editable extra match keys (Q56)+    var note: String = ""+    var factsData: Data?              // [CharacterFact] canonical JSON (Q57, Q75)+    var createdAt: Date = ...+    var modifiedAt: Date = ...+    var work: Work?                   // nullify + inverse Work.characters (Q58)+}+struct CharacterFact: Codable {       // value type in AsterismCore+    var statement: String+    var quote: String                 // immutable evidence (Q74)+    var nameKey: String               // the key it was accepted under (Q79)+    var source: SourceRef             // .entry(UUID) | .genericNotes+}+```++Coverage: `Entry.characterExtractionFingerprint: String?` and+`Work.genericNotesExtractionFingerprint: String?` (Q59) — derived fields,+excluded from authored content (the `chapterTitle`-when-parsed precedent,+`GroupOrdering.swift:139-141`). Fingerprint = SHA-256 of source text (the+`VariantID` recipe).++Suppression: `CharacterSuppression` model (Q60/Q72): `id`, `work: Work?`+(nullify + inverse), `kindRaw` (candidate/fact), `nameKey`, `sourceKindRaw`+(entry/genericNotes, fact rows only), `sourceEntryID: UUID?` (entry kind+only), `evidence: String?`, `statusRaw` (active/cleared), `actionAt: Date`.+Writes update the local row in place, keyed by (work, kind, nameKey, source,+evidence); rows duplicated by sync are read through: latest `actionAt`,+tie-broken cleared-wins then lowest row UUID (Q82). A clear lost to a skewed+clock re-suppresses at worst — the reader clears again; accepted.++`CharacterAuthoredContent: AuthoredContent` — `orderComponents`: name, note,+canonical `factsData` (sorted-keys JSON, facts ordered by (source, quote,+statement) — Q75, total even with edited-apart copies), sorted aliases; never+bare. The identity triple is not unique within a character (edited-apart+copies share one); a triple suppression covers every copy. Suppression and coverage are+`NoAuthoredContent`-class system records.++Migration (Req 6.8): freeze `AsterismSchemaV6.swift` to the V5-snapshot shape,+add `AsterismSchemaV7` + lightweight stage. **The plan stays `[V5, V6, V7]`**+(Q80): retiring the V5→V6 stage carries retire-migration-chain Decision 6's+population precondition and buys nothing here; V5-seeded fixtures keep+opening. Marker `"7"`, `appOpenableMarkerVersions` gains the lagging-V6 case,+new `BootstrapState` case, per the six-row checklist in+`docs/agent-notes/schema-migration.md`. The update window is covered by the+pending-capture queue. After migration, run the `Development` configuration so+`NSPersistentCloudKitContainer` publishes the two new record types and two new+fields to the shared dev container before any second device syncs (Q86;+production promotion rides the design doc's §13.2 path unchanged).++### Duplicate/torn machinery (Req 6.4/6.5)++**Character duplicate sets are bucketed by application UUID only** (Q76) —+never by content, so distinct-UUID duplicates never form a set and `.merge` is+structurally unreachable for characters, per Req 6.4. Same-UUID rows with+agreeing authored content converge silently; divergent rows tear and route to+the resolution sheet (chosen-only, no union — the `.work` arm's write shape,+not the `.entry` arm's note-append). Torn disclosure (Req 6.5) reuses the+existing surfaces: a `.character` arm in the duplicate-resolution sheet and+model, and the work page's existing top-slot attention card names torn+characters alongside torn works; torn-acceptance refusals (Req 2.8) route the+reader there.++Pattern-extension audit — **AsterismCore sites**:++| Site | Needs equivalent | What |+|---|---|---|+| `DuplicateRecordType` enum (`DuplicateScan.swift:17`) | yes | `case character` **appended last** (order feeds `DuplicateSetKey` sort) |+| Set formation (`DuplicateScan.run`, `:155-226`) | yes | UUID-only bucketing (Q76); joins `DuplicateScanResult` as `characterSets`, `isEmpty`/`setCount` updated |+| Resolve switch (`LibraryRepository+DuplicateResolution.swift:86-116`) | yes | character arm: same-UUID convergence + chosen-only variant resolution |+| Contract projection (`:164-197`) | yes | character contract arm |+| `CollapsedRecordType` ternary (`:120-122`) and switch (`LibraryRepository.swift:525-530`) | yes | explicit character case |+| `DuplicateReconciler.stage` (`DuplicateReconciler.swift:853-881`) + `DeletionRows` (`:894-935`) | yes | character staging arm + rows list (equality filters exclude unknown types silently) |+| `DuplicateReconciler.run` ledger retain (`:224-226`) | yes | retains character set keys — otherwise every pass evicts their settled state |+| Entry collapse repoint (`:859-861`, `LibraryRepository+DuplicateResolution.swift:400-411`) | yes | repoint fact citations + suppression `sourceEntryID` to survivor (Req 3.6): decode `factsData`, rewrite, re-encode canonically **on every row of each affected character group in the same transaction**, so the group cannot false-tear (Q85) |+| Work merge (`LibraryRepository+WorkMerge.swift:256-267,303`) | yes | `repointCharacters` (the `repointEntries` shape, `DuplicateReconciler.swift:631-641`) + suppression move/union + coverage reset + generic-notes citations repoint (Req 3.4) |+| Work deletion (repository delete path) | yes | deletes characters, suppressions, coverage; `reconcile()` drops held proposals (Req 3.4) |+| `WorkMergeContract` | yes | carries character counts for an honest merge preview |+| `DuplicateWorkload` (`DuplicateWorkload.swift:170-211`) | yes | characters join (reader-authored); UUID-grouped scan only — no content bucketing — keeping the added cost inside Req 10.1's publication budget |+| `BackupGroupProjection` (`BackupGroupProjection.swift`) | yes | character groups join the projection and `TornGroupsPayload` — the torn-export refusal has no site otherwise |++**App-target sites** (same audit, second table):++| Site | Needs equivalent | What |+|---|---|---|+| `RecentView.swift:677-679` (recordType filters) | yes | character review items get their own arm — the filter otherwise mis-buckets them |+| `MaintenanceViewModels.swift:280-281` (work-else-entry ternary) | yes | explicit character label |+| `DuplicateResolutionModel.swift:68-76` | yes | `characterVariants` + rendering arm |+| Duplicate-resolution sheet view | yes | character variant rows (name, note, fact count) |+| `AppLibraryModel` conflict routing (`:725/742/933/987`) | yes | callers pass `.character` where `commitCharacterDecision`/edit conflicts surface; `recordConflict(_:recordType:)` itself is type-generic |++### Archive generation 6/7 (Req 6.1/6.2, Q63)++Clone `BackupV5Types/Codec/Exporter` → `BackupV6*` at `(6, 7)`; payload = the+six frozen arrays + `characters`, `suppressions`, `coverage` (entry/work UUID++ fingerprint pairs). The exporter enumerates **all characters and+suppressions**, not works→children — a sync-orphan exports with a nil work+reference rather than silently vanishing from the backup (Q78). Reference+validator: a character's work reference is **optional, checked when present**+(the `validateEntry` `workID` pattern, `BackupArchiveReferenceChecks.swift:219-223`)+— an orphan passes, a reference to a nonexistent work fails; a fact's+`sourceEntryID` is deliberately unchecked (Decision 2), both exemptions pinned+by tests. Export refuses torn character groups via the extended group+projection. Import: `mergeImportedCharacters` in the+`BackupImportWorkTypes.swift` shape (UUID-keyed, match-guarded, value-guarded+by `modifiedAt`/`actionAt`, idempotent). Coverage pairs are self-validating:+imported only where the archived fingerprint matches the current source+text's fingerprint, else dropped (Q81) — no timestamp needed. Importer+accepts 4/4, 5/6, 6/7.++### Review and display surfaces (Req 2, 5)++| Surface | Pattern to copy | Integration point |+|---|---|---|+| Proposals indicator | `duplicateReviewSection` card register (amber border, `WorkDetailView.swift:635-662`) | top-of-list slot beside line 99 |+| Review list | `PostTeachingWorkURLModel` queue + sparkles badge `ComposedTeachingView.swift:236-246` | sheet from the indicator; candidates with per-fact ticks, bundles beside the target's existing facts (Req 2.1). The sheet snapshots its proposals at open (Req 2.7): later sweep results appear on next open, and the only in-place refresh is the commit-refusal disclosure |+| Characters section (view mode) | `chapterSection` idiom (`WorkDetailView.swift:459-482`) | between `viewNotesSection` and `chapterSection` (line 112); fact order: generic-notes facts, then live citations by capture order, then dangling citations last, stable by (quote, statement) (Q88) |+| Character combine (Req 3.7, Decision 4) | work merge's "Merge into…" affordance and semantics (`LibraryRepository+WorkMerge.swift`) | edit-mode action per character: target keeps name/retained key/UUID. Alias union covers the source's **match keys** — current-name key, retained key (stored as an alias entry even when it survives only as a bare key string; alias matching normalises anyway), and aliases — deduped against the target's own current/retained/alias keys (Q91). Facts move re-keyed to the target's retained key (Q79); identity-triple duplicates drop, except both copies survive where their statements were edited apart. The source's **active fact suppressions re-key to the target's retained key** in the same transaction, merging with the target's rows by Q82's in-place key. Notes append under a divider. Combine's writes fan out to **every row of the target's character group**, and the source's group deletes whole — the work-merge rule (`LibraryRepository+WorkMerge.swift:274-276,300-303`), or the combine itself tears the group. The source's deletion counts as Req 2.8 deletion for held proposals (sweep bundles discarded and re-derived; manual-pass ones gone per 2.6) but writes **no new suppressions**. Torn source or target refuses (edit-mode gate). Combine is **staged in the edit session** (Q97): discardable by the session's X until commit; staged character operations apply in the order performed inside the single edit-step repository call, basis-verified on both characters with names as they stand at commit. Sync races (delete-vs-remote-edit, reciprocal combines) resolve by CloudKit's last-writer rules and are accepted consequences (Decision 4) — Req 3.7's no-loss guarantee is scoped to the combine itself |+| Character editing/creation | edit-mode sections | `commitEditing()` step between metadata save and close, guard-and-stay like the URL half (`WorkDetailModel.swift:323-338`); character drafts captured before `save()`'s `load()`. One repository call commits the session's character changes against per-character `CharacterEditBasis` snapshots — on any mismatch the whole step refuses, `errorMessage` names the character, editor stays. The same call handles hand-creation key minting, Q44 suppression-clears, and deletion's suppression writes (retained + current + alias keys, fact triples — Req 3.3) |+| Entry-detail section | section idiom of `EntryDetailView` | new field on `EntryTeachingDetail` (populated in the single locked context, `LibraryRepository+EntryDetail.swift:13`), never a second read; section between actions and capture details |+| Manual-pass trigger + outcome | `suggestRow` + notice (`ComposedTeachingView.swift:262-305`) | work page; hidden when model unavailable (Req 1.11) |++Review decisions commit one at a time through `commitCharacterDecision`,+which re-verifies inside the transaction: source fingerprints (Req 2.7), the+work/character group state (Req 2.8), and the match resolution — a bundle+whose match no longer resolves to the **displayed** target, or a candidate+displayed as new that now resolves onto an existing character, refuses as+stale; the refreshed list re-presents that candidate as a bundle, so the+reader is not looped through repeated refusals. It writes accepted content + completed-source coverage ++suppression/clears in one save and returns a refusal the sheet discloses and+refreshes on.++Orphan arrival (Req 6.7): a `Character` or `CharacterSuppression` whose+`work` is nil (child synced first) is inert — reachable only through its+work, so it displays nowhere, is never flagged by integrity scans (the+`workTypeID` tolerated class), and appears when the work lands.++### Diagnostics++`CharacterExtraction` category beside `RuleSuggestion`, same subsystem, same+redaction rule (content `.private` in release — Req 1.9): attempt+start/settle with model phase, refusals, grounding drops, lane waits, budget+exhaustion, coverage writes.++## Error Handling++| Failure | Behaviour |+|---|---|+| Model unavailable | Sweep no-ops; manual trigger hidden (Req 1.11); logged reason only |+| Refusal / timeout / decode failure | Source skipped, attempted-this-run, uncovered; sweep continues (Req 1.8) |+| Oversized source | Skipped, uncovered, logged (Q35); no truncation |+| Lane wait / refusal (`.background`) | Not a settlement: no budget charge, no attempt memory; claim queues FIFO |+| Background attempt pre-empted | Model phase charges budget; not attempted, stays uncovered, may retry this run |+| Stale proposal at accept (fingerprint changed or bundle target re-routed) | Repository refuses; sheet discloses and refreshes (Req 2.7) |+| Torn work/character at accept | Repository refuses with disclosure routing to the resolution surface; skip still records (Req 2.8) |+| Budget exhausted | `.background` refused for the rest of the run; manual unaffected |+| Character edit conflict (basis mismatch) | Whole character step refuses; editor stays, names the character |++## Testing Strategy++- **Lane**: contention matrix — interactive pre-empts background,+  cross-feature FIFO, feature-internal swap keeps the slot, token release on+  cancellation/deinit (no stranded slot), background-vs-background+  serialisation.+- **Ledger**: state-machine tests in the `RuleSuggestionLedger` style —+  attempt memory (incl. not-on-cancel), budget charging, sweep generations,+  reconcile-driven invalidation (deleted work, deleted character, changed+  corpus — Req 2.8).+- **Grounding (PBT-style)**: hand-rolled generators over (note, output) pairs+  asserting: grounded quotes are substrings; no absent-name candidates; caps+  hold; keying idempotent and locale-stable (Turkish-I case); resolved-key+  canonicalisation dedups alias spellings (Q79).+- **Client**: the `FoundationRuleSuggestionModelClient` test pattern — one+  live call, nested `withKnownIssue`.+- **Repository**: decision commits (accept/skip/untick × stale/re-routed/torn+  /clean), suppression LWW + tie-break, coverage advance rules incl.+  manual-pass writes, merge/collapse repointing without false tears (Q85),+  work-deletion cascade, edit-step basis refusal — against seeded stores+  incl. torn character groups and sync orphans. **Combine**: match-key union+  incl. bare retained key, fact re-key + duplicate drop + edited-apart+  survival, suppression re-key merging, note append, two-row basis refusal,+  torn gates both sides, held-proposal discard, deleting a combined character+  suppresses alias keys (Req 3.3).+- **Slash-split**: split/ground/assemble incl. multi-slash and non-grounding+  halves; struck alias not suppressed on skip; unstruck halves suppressed and+  cleared together; name-half-only matching.+- **Migration**: V5→V7 and V6→V7 conversions, V4 refusal, marker/bootstrap+  coverage extensions; fixtures seed through the frozen V6 snapshot.+- **Archive**: 6/7 round-trip incl. suppressions and self-validating+  coverage; orphan character export/import; dangling-citation and+  orphan-work-reference exemption pins; torn refusal; pre-feature archive+  import; idempotent re-import.+- **UI**: stub-client tests for indicator → review → accept → section;+  manual-pass outcomes; snapshot-at-open sheet semantics.
specs/character-extraction/implementation.md Added +216 / -0
diff --git a/specs/character-extraction/implementation.md b/specs/character-extraction/implementation.mdnew file mode 100644index 0000000..50ab458--- /dev/null+++ b/specs/character-extraction/implementation.md@@ -0,0 +1,216 @@+# Implementation Explanation: Character Extraction (T-2229)++Written after the pre-push review, against the branch as it stands at that+review's fixes (`origin/main..HEAD`, 29 feature commits plus review-fix+commits). Three levels, then a completeness assessment.++## Beginner Level++### What This Does++Asterism is an app where a reader keeps notes about the works they read. This+feature makes the phone itself read those notes and suggest the characters it+finds in them — like a librarian going through your note cards and drafting an+index card per character: "Hanna — 'the sister who stayed behind'", with the+exact sentence from your note that says so.++Three rules make it trustworthy:++1. **Everything is a quote.** A suggested fact is always a verbatim snippet+   from one of your own notes, with a link to which note it came from. The+   model is not allowed to paraphrase into the record; if the exact words are+   not in your note, the fact is thrown away before you ever see it.+2. **Nothing is saved until you say so.** Suggestions wait in a review sheet.+   You accept a character, tick or untick individual facts, strike a suggested+   alias, or skip the whole candidate. Skipping is remembered so you are not+   nagged about the same name again.+3. **It all happens on the phone.** The model is Apple's on-device one. Notes+   never leave the device for this feature, and it only runs in the background+   when conditions are good (not in Low Power Mode, not when the phone is hot).++Accepted characters become normal data of yours: shown on the work's page,+editable, combinable when the model split one person across two names,+deletable, synced between your devices, and included in backups.++### Why It Matters++The reader's notes already contain who everyone is — but spread across dozens+of entries. This collects that knowledge into one place per work without the+reader doing the collation, and without ever inventing anything the reader+didn't write.++### Key Concepts++- **Work / Entry**: a work is the thing being read; entries are the reader's+  captured notes about it.+- **Candidate**: a character the model proposes, waiting for review.+- **Fact**: one statement about a character, always paired with its verbatim+  quote and the note it cites.+- **Suppression**: the memory of "I said no to this" — durable and synced.+- **Sweep**: the bounded background pass that looks for new characters.++## Intermediate Level++### Changes Overview++Three layers, matching the package structure:++- **`AsterismCore`** (store): schema V7 freezes V6 and adds `Character` and+  `CharacterSuppression` entities plus per-source coverage fingerprints on+  `Entry`/`Work` (`Entry.characterExtractionFingerprint`,+  `Work.genericNotesExtractionFingerprint`). The Swift type is+  `CharacterRecord` (the stdlib owns `Character`); the entity, CloudKit record+  type, and archive keys all stay `"Character"`. Repository surface:+  `characterExtractionCandidates` (one locked-context read),+  `commitCharacterDecision` (accept/skip with commit-time gates),+  `advanceCharacterCoverage`, `commitCharacterEdits` (staged+  create/edit/delete/combine as one step), work merge/delete integration, and+  `EntryTeachingDetail.citingCharacters`. Characters join the duplicate/torn+  machinery as a third record family, and archive generation 6/7+  (`BackupV6*`) round-trips characters, suppressions, and coverage while+  still importing 4/4 and 5/6.+- **`AsterismIntelligence`** (pipeline): `ModelLane`, an app-wide single-slot+  actor arbitrating all on-device model use (interactive asks a background+  holder to yield — two-step, never a seizure); the FoundationModels+  extraction client (greedy sampling, guided generation into+  `ExtractionResult`); grounding (verbatim-quote, name-presence, and length+  checks — over-long values are dropped, never truncated); the assembler+  (slash-compound splitting into name + proposed aliases, canonical name+  keys, Req 2.3 matching via the single `CharacterMatching` implementation);+  and `CharacterExtractionLedger` (held proposals merged per name key per+  work, per-run attempt memory, budgets).+- **App target**: `CharacterExtractionCoordinator` (activation sweep + manual+  pass) wired beside the existing rule-suggestion coordinator, the review+  sheet (`CharacterReviewModel`/`View`), the work-page Characters section+  with staged edit-mode operations, entry-detail citations, the+  torn-character resolution arms, and the Settings backup surface moved to+  generation 6/7.++### Implementation Approach++- **TDD throughout**: every implementation task was preceded by a failing+  suite; the branch adds ~15 test suites across store, pipeline, app models,+  and stub-driven UI journeys (no live model in app/UI tests; the package+  keeps one guarded live-call check per model client).+- **Decisions, not data**: the pipeline writes nothing the reader didn't+  approve. Held proposals live in the ledger (in-memory, per-run); the only+  durable writes are the reader's decisions, coverage fingerprints, and+  suppressions.+- **Canonical bytes where sync can compare**: fact lists are encoded with+  sorted keys and a single shared `OutputFormatting` constant; name keys use+  one normalisation recipe (`CharacterNameKey.normalize`, trim → NFC →+  locale-free case fold → repeat-until-stable article strip, Q99); SHA-256+  hex has one implementation. Divergent spellings of any of these were the+  main thing the post-integration reviews hunted down, because two spellings+  mean two devices routing one proposal onto two characters.+- **Precedent-following**: the coordinator/ledger/log/environment shapes+  mirror rule-suggestion deliberately, and the genuinely identical halves+  were extracted rather than copied (`ModelLane`, `ModelWorkEnvironment`,+  `FoundationModelDiagnostics`, `SweepGate`, `PipelineLog`,+  `withAttemptTimeout`).++### Trade-offs++- **Parallel coordinators over one generic pipeline**: rule suggestion and+  character extraction stay separate coordinators sharing extracted parts,+  rather than one abstract "model work" framework. Two features was judged+  too early to force an abstraction; the lane and environment are shared+  where sharing is load-bearing.+- **UUID-only duplicate sets (Q76)**: characters can only be duplicates of+  themselves (same UUID arriving twice via sync), never merged by content+  key — so `.merge` is structurally unreachable and combining is always the+  reader's explicit act (Decision 4).+- **Per-source model requests (Req 1.4)**: one note per request bounds+  context, keeps citations mechanical (the citation is which source was+  sent), at the cost of more requests per work.+- **Suppressions accrete (Q82)**: clears are status flips, not deletions, so+  last-writer-wins convergence stays simple; the cost is monotonic table+  growth, which the review consequently kept off every interactive read path.++## Expert Level++### Technical Deep Dive++- **Commit-time gates**: `commitCharacterDecision` re-validates inside the+  transaction — stale source fingerprint, re-routed match (the displayed+  target is verified against a fresh resolution; a mismatch refuses with+  `reRouted(to:)`), and tornness. The refusal paths are functional, not+  decorative: a re-route retargets the held proposal via the ledger and the+  sheet re-reads (Q66/Q110); a stale-source refusal drives `reconcile()`+  before the sheet claims freshness (Req 2.7).+- **Edit-step basis contract (Q108)**: within one staged step, a character's+  basis is verified on first touch only; later operations trust the+  transaction's intermediate state. This is what makes combine-then-tidy+  committable while still catching genuinely external change. The derived+  update applies only fields the draft changed (`deletingOmitted:` guards+  facts moved in by the combine).+- **Sweep scheduling**: two works per activation, chosen by recency, skipping+  works whose uncovered sources are all attempted this run (Q106); the stop+  signal is checked per source (Q110); a manual pass claims the lane+  interactively, ignores coverage and budget (Q101), dedups against accepted+  facts, and never regresses coverage.+- **Ordering duality**: facts have exactly two orders — canonical+  (source-UUID-based, for encoding/merge/dedup, Q75) and capture order (for+  every display surface, Q88). The review sheet derives capture order from+  the same locked read that feeds the work page, so the two surfaces cannot+  disagree.+- **Archive generation 6/7**: `BackupV6*` mirrors the frozen-generation+  pattern; the exporter consumes the single group projection (one pass over+  the store), suppressions export unfolded one-per-row (Q82's convergence+  needs them), coverage is self-validating (Q81), a dangling fact citation is+  tolerated on export (Decision 2) while a torn character refuses export on+  every generation's path (Q105), and `modifiedAt` is the import value guard+  — which is why collapse repointing clamps it monotonically (a backwards+  stamp would let an older archive overwrite a newer character).++### Architecture Impact++- Schema chain is now `[V5, V6, V7]` with marker generation `"7"` and a+  lagging-V6 bootstrap state that owes only a republication; V6 is frozen.+- The duplicate/torn machinery is now genuinely polymorphic over three record+  families; the character arm exercises the UUID-only degenerate case end to+  end (scan gating, converge, collapse repoint with group-wide canonical+  rewrite, resolution UI).+- `AsterismIntelligence` is now a two-client module with shared arbitration;+  a third model feature would reuse `ModelLane`, `SweepGate`,+  `ModelWorkEnvironment`, `FoundationModelDiagnostics`, `withAttemptTimeout`,+  and `PipelineLog` as-is.+- The share extension still links only `AsterismCore` + `ConstellationKit` —+  verified at the pbxproj level; keeping FoundationModels out of the+  extension is a standing constraint.++### Potential Issues++- **Unmeasured perf participation**: characters joined the duplicate-scan and+  publication paths inside Req 10.1's budget by construction (two-walk gate,+  UUID-only candidates), but no M4 perf run has measured it. Run+  `make test-performance-m4` (~20 min, host-only) before trusting the budget+  claim.+- **Sweep read breadth (Q78)**: the unscoped candidates read fetches the+  whole library by design; if libraries grow much larger this is the knob to+  revisit. All interactive paths were moved off whole-table scans in the+  pre-push review.+- **Latent log-privacy risk (shared with rule suggestion)**:+  `FoundationModelDiagnostics.describe` interpolates+  `String(describing: GenerationError)` into an always-public field; today's+  cases carry no reader content, but a future FoundationModels case might.+- **CloudKit dev-schema publication (Q86)** is a user-side step: run the+  `Development` configuration once after migration so the two new record+  types publish before any second device syncs the dev library.+- **Remaining known test gaps** (accepted, low-risk): Q82's third tie-break+  leg, reconciler ledger-retain of character sets, combine onto a two-row+  target group, the 4/4 torn-export pin, and structural-only UI render pins.++## Completeness Assessment++- **Fully implemented**: requirement groups 1 (sweep/extraction), 2+  (review/acceptance), 3 (character record and combine), 4 (updates), 5+  (display), 6 (durability/sync/archive) — several amended by recorded+  decisions (Q99–Q112), none contradicted by code. All 27 tasks complete;+  the four-agent pre-push review walked every requirement and found no+  missing one.+- **Partially implemented**: nothing in code. Req 10.1 participation is+  asserted by construction but unmeasured (see above).+- **Missing / deferred to the user**: the three user-side prerequisites in+  `prerequisites.md` — notably the Q86 publication run and a fresh Personal+  backup before updating the daily-use install.
specs/character-extraction/prerequisites.md Added +26 / -0
diff --git a/specs/character-extraction/prerequisites.md b/specs/character-extraction/prerequisites.mdnew file mode 100644index 0000000..1fdaa7c--- /dev/null+++ b/specs/character-extraction/prerequisites.md@@ -0,0 +1,26 @@+# Prerequisites for Character Extraction++These tasks must be completed by the user before or during implementation.++## Before Starting++- [ ] Export a fresh full-library backup from the `Personal` app on the phone.+      The V7 migration will run on real data eventually; a current archive is+      the safety net, and the prototype's 2026-08-08 archive is already stale.++## During Implementation++- [ ] After task 2 lands (schema V7), run the `Development` configuration once+      on a signed-in device or simulator so `NSPersistentCloudKitContainer`+      publishes the `Character` and `CharacterSuppression` record types and the+      two new fingerprint fields to the dev CloudKit container (design §Data+      model, Q86). Needed before any second dev device syncs.++## Before Testing++- [ ] Any install or run on the physical iPhone (`make install`, `make run`,+      `Personal` builds) requires explicit approval at the moment of running,+      per CLAUDE.md — the tasks above do not constitute approval.+- [ ] The live-model test in task 16 needs a host with Apple Intelligence+      available (this Mac qualifies today); on hosts without it the test+      degrades to a known issue, which is expected.
specs/character-extraction/prototype/.gitignore Added +1 / -0
diff --git a/specs/character-extraction/prototype/.gitignore b/specs/character-extraction/prototype/.gitignorenew file mode 100644index 0000000..30bcfa4--- /dev/null+++ b/specs/character-extraction/prototype/.gitignore@@ -0,0 +1 @@+.build/
specs/character-extraction/prototype/Package.swift Added +13 / -0
diff --git a/specs/character-extraction/prototype/Package.swift b/specs/character-extraction/prototype/Package.swiftnew file mode 100644index 0000000..aae1f31--- /dev/null+++ b/specs/character-extraction/prototype/Package.swift@@ -0,0 +1,13 @@+// swift-tools-version: 6.0+// Prototype spike for T-2229 (Q2/Q28): host-side character extraction over a+// real exported archive's notes. Standalone on purpose — no AsterismCore+// dependency, reads the backup JSON directly.+import PackageDescription++let package = Package(+    name: "CharacterExtractionPrototype",+    platforms: [.macOS("26.0")],+    targets: [+        .executableTarget(name: "CharacterExtractionPrototype", path: "Sources")+    ]+)
specs/character-extraction/prototype/Sources/main.swift Added +241 / -0
diff --git a/specs/character-extraction/prototype/Sources/main.swift b/specs/character-extraction/prototype/Sources/main.swiftnew file mode 100644index 0000000..63e616b--- /dev/null+++ b/specs/character-extraction/prototype/Sources/main.swift@@ -0,0 +1,241 @@+// Prototype spike for T-2229 (Q2/Q28).+//+// Reads a backup archive (format 4/5 JSON), selects the most note-rich works,+// runs one model request per source (Q15/Q42: one entry note + display title),+// grounds the output (Q14: evidence spans verbatim, names present), and runs+// the whole pass twice to measure equivalence at (name key, source, evidence)+// granularity post-grounding pre-dedup under greedy decoding (Q32).+//+// Usage: swift run CharacterExtractionPrototype <archive.json> [maxWorks]++import Foundation+import FoundationModels++// MARK: - Archive decoding (just what the spike needs)++struct Archive: Decodable {+    let payload: Payload+    struct Payload: Decodable {+        let entries: [Entry]+        let works: [Work]+    }+    struct Entry: Decodable {+        let id: String+        let note: String+        let workID: String?+        let firstCapturedAt: String+    }+    struct Work: Decodable {+        let id: String+        let displayTitle: String+        let genericNotes: String+    }+}++// MARK: - Model I/O (flat strings, house pattern; never an identifier)++@Generable+struct ExtractedFact {+    @Guide(description: "One short statement about the character, in third person.")+    var statement: String+    @Guide(description: "The exact words from the note this statement comes from, copied verbatim. Never paraphrase.")+    var quote: String+}++@Generable+struct ExtractedCharacter {+    @Guide(description: "The character's name exactly as the note spells it.")+    var name: String+    @Guide(description: "Facts the note states about this character. Empty if the note only mentions the name.")+    var facts: [ExtractedFact]+}++@Generable+struct ExtractionResult {+    @Guide(description: "Characters this note mentions. Empty if it mentions none. Only people or beings in the story — never the reader, the author, or the app.")+    var characters: [ExtractedCharacter]+}++let instructions = """+You extract story characters from a reader's private note about one chapter of \+a serial story. The note is informal and may be short. Report only characters \+the note itself mentions — never use outside knowledge of any story, and never \+invent characters or facts. For every fact you must copy the supporting words \+from the note verbatim into the quote field. If the note mentions no \+characters, return an empty list.+"""++func prompt(title: String, note: String) -> String {+    """+    The story is titled "\(title)". The reader's note about one chapter:++    \(note)++    List the characters this note mentions, with any facts it states about them.+    """+}++// MARK: - Grounding + keys++func nameKey(_ s: String) -> String {+    s.trimmingCharacters(in: .whitespacesAndNewlines)+        .precomposedStringWithCanonicalMapping+        .folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)+}++struct GroundedFact: Hashable {+    let nameKey: String+    let sourceID: String+    let quote: String+    let statement: String+    let name: String+}++struct SourceOutcome {+    var grounded: [GroundedFact] = []+    var nameOnly: [String] = []          // grounded, zero facts+    var droppedFacts: [(String, String)] = []   // (name, reason)+    var droppedCandidates: [(String, String)] = []+    var error: String? = nil+    var seconds: Double = 0+}++func ground(_ result: ExtractionResult, sourceID: String, sourceText: String) -> SourceOutcome {+    var out = SourceOutcome()+    let haystack = sourceText.precomposedStringWithCanonicalMapping+    for character in result.characters {+        let name = character.name.trimmingCharacters(in: .whitespacesAndNewlines)+        guard !name.isEmpty else { out.droppedCandidates.append(("", "empty name")); continue }+        guard haystack.range(of: name, options: [.caseInsensitive]) != nil else {+            out.droppedCandidates.append((name, "name not in source")); continue+        }+        let key = nameKey(name)+        var kept = 0+        for fact in character.facts {+            let quote = fact.quote.trimmingCharacters(in: .whitespacesAndNewlines)+            if !quote.isEmpty, haystack.range(of: quote, options: [.caseInsensitive]) != nil {+                out.grounded.append(GroundedFact(nameKey: key, sourceID: sourceID, quote: quote, statement: fact.statement, name: name))+                kept += 1+            } else {+                out.droppedFacts.append((name, quote.isEmpty ? "empty quote" : "quote not verbatim"))+            }+        }+        if kept == 0 { out.nameOnly.append(name) }+    }+    return out+}++// MARK: - Run++@main+struct Spike {+    static func main() async throws {+        let args = CommandLine.arguments+        guard args.count >= 2 else { fatalError("usage: <archive.json> [maxWorks]") }+        let maxWorks = args.count >= 3 ? Int(args[2]) ?? 6 : 6++        let archive = try JSONDecoder().decode(Archive.self, from: Data(contentsOf: URL(fileURLWithPath: args[1])))+        let model = SystemLanguageModel.default+        guard model.isAvailable else { fatalError("model unavailable: \(model.availability)") }++        // Pick the most note-rich works.+        let byWork = Dictionary(grouping: archive.payload.entries.filter { !$0.note.isEmpty && $0.workID != nil }, by: { $0.workID! })+        let works = archive.payload.works+            .compactMap { w -> (Archive.Work, [Archive.Entry])? in+                guard let es = byWork[w.id], es.count >= 4 else { return nil }+                return (w, es.sorted { $0.firstCapturedAt < $1.firstCapturedAt })+            }+            .sorted { $0.1.count > $1.1.count }+            .prefix(maxWorks)++        print("Model available. Works selected: \(works.count)")+        for (w, es) in works { print("  - \(w.displayTitle): \(es.count) noted entries") }++        var report = "# Prototype findings — run of \(Date.now.formatted(.iso8601))\n\n"+        var run1All: [String: Set<GroundedFact>] = [:], run2All: [String: Set<GroundedFact>] = [:]+        var totalRequests = 0, totalErrors = 0+        var timings: [Double] = []++        for runIndex in 1...2 {+            print("=== Run \(runIndex) ===")+            for (work, entries) in works {+                var outcomes: [(Archive.Entry, SourceOutcome)] = []+                for entry in entries {+                    let session = LanguageModelSession(model: model, instructions: instructions)+                    let start = ContinuousClock.now+                    var outcome: SourceOutcome+                    do {+                        let response = try await session.respond(+                            to: prompt(title: work.displayTitle, note: entry.note),+                            generating: ExtractionResult.self,+                            options: GenerationOptions(sampling: .greedy)+                        )+                        outcome = ground(response.content, sourceID: entry.id, sourceText: entry.note)+                    } catch {+                        outcome = SourceOutcome(); outcome.error = "\(error)"+                        totalErrors += 1+                    }+                    outcome.seconds = Double((ContinuousClock.now - start).components.seconds)+                        + Double((ContinuousClock.now - start).components.attoseconds) / 1e18+                    timings.append(outcome.seconds)+                    totalRequests += 1+                    outcomes.append((entry, outcome))+                }+                let facts = Set(outcomes.flatMap { $0.1.grounded })+                if runIndex == 1 { run1All[work.id] = facts } else { run2All[work.id] = facts }++                if runIndex == 1 {+                    report += "## \(work.displayTitle) (\(entries.count) sources)\n\n"+                    var byKey: [String: [GroundedFact]] = [:]+                    for f in facts { byKey[f.nameKey, default: []].append(f) }+                    let nameOnly = Set(outcomes.flatMap { $0.1.nameOnly }.map(nameKey))+                        .subtracting(byKey.keys)+                    for (key, fs) in byKey.sorted(by: { $0.value.count > $1.value.count }) {+                        report += "- **\(fs.first!.name)** (key `\(key)`, \(fs.count) facts)\n"+                        for f in fs.sorted(by: { $0.quote < $1.quote }).prefix(6) {+                            report += "    - \(f.statement) — \"\(f.quote)\"\n"+                        }+                    }+                    for n in nameOnly.sorted() { report += "- \(n) (name only)\n" }+                    let droppedC = outcomes.flatMap { $0.1.droppedCandidates }+                    let droppedF = outcomes.flatMap { $0.1.droppedFacts }+                    let errs = outcomes.compactMap { $0.1.error }+                    report += "\nDropped candidates: \(droppedC.count) \(droppedC.map { "\($0.0.isEmpty ? "?" : $0.0)[\($0.1)]" })\n"+                    report += "Dropped facts: \(droppedF.count) (\(droppedF.filter { $0.1 == "quote not verbatim" }.count) non-verbatim)\n"+                    if !errs.isEmpty { report += "Errors: \(errs.count) — \(errs.prefix(2))\n" }+                    report += "\n"+                }+                print("  [run \(runIndex)] \(work.displayTitle): \(facts.count) grounded facts")+            }+        }++        // Equivalence (Q32): post-grounding, pre-dedup, (name key, source, quote).+        report += "## Equivalence across runs (greedy)\n\n"+        for (work, _) in works {+            let a = Set((run1All[work.id] ?? []).map { [$0.nameKey, $0.sourceID, $0.quote].joined(separator: "|") })+            let b = Set((run2All[work.id] ?? []).map { [$0.nameKey, $0.sourceID, $0.quote].joined(separator: "|") })+            let inter = a.intersection(b).count+            let union = a.union(b).count+            let jaccard = union == 0 ? 1.0 : Double(inter) / Double(union)+            let names1 = Set((run1All[work.id] ?? []).map(\.nameKey))+            let names2 = Set((run2All[work.id] ?? []).map(\.nameKey))+            let nameJaccard = names1.union(names2).isEmpty ? 1.0+                : Double(names1.intersection(names2).count) / Double(names1.union(names2).count)+            report += "- \(work.displayTitle): fact-triple Jaccard \(String(format: "%.2f", jaccard)) (\(inter)/\(union)), name-key Jaccard \(String(format: "%.2f", nameJaccard))\n"+        }++        let sorted = timings.sorted()+        report += "\n## Timings\n\n"+        report += "- Requests: \(totalRequests), errors: \(totalErrors)\n"+        report += "- Median \(String(format: "%.1f", sorted[sorted.count/2]))s, p90 \(String(format: "%.1f", sorted[Int(Double(sorted.count) * 0.9)]))s, max \(String(format: "%.1f", sorted.last ?? 0))s\n"++        let out = URL(fileURLWithPath: args[1]).deletingLastPathComponent()+            .appendingPathComponent("prototype-findings.md")+        // Write next to the binary's CWD instead: keep it simple — current directory.+        let dest = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)+            .appendingPathComponent("prototype-findings.md")+        try report.write(to: dest, atomically: true, encoding: .utf8)+        _ = out+        print("Report written to \(dest.path)")+    }+}
specs/character-extraction/prototype/prototype-findings.md Added +308 / -0
diff --git a/specs/character-extraction/prototype/prototype-findings.md b/specs/character-extraction/prototype/prototype-findings.mdnew file mode 100644index 0000000..4f26221--- /dev/null+++ b/specs/character-extraction/prototype/prototype-findings.md@@ -0,0 +1,308 @@+# Prototype findings — run of 2026-08-20T12:23:35Z++## The Secret Return of Alex Mack (40 sources)++- **Terawatt** (key `terawatt`, 14 facts)+    - finishes off the silicates — "God chapter where they finish off the silicates and turn then spend time looking for more."+    - Terawatt try to take him too lightly in some ways. — "I feel like Jack and even Terawatt try to take him too lightly in some ways, or rather act too much like they know everything (comes back to the whole thing where she got years of knowledge in 5 days)."+    - Terawatt changed back into Alex. — "It went well, and then Terawatt headed home again where she changed back into Alex and had a quick 3am meal with her parents."+    - Terawatt headed home. — "It went well, and then Terawatt headed home again where she changed back into Alex and had a quick 3am meal with her parents."+    - He confronted the bat on his roof. — "Terawatt confronting the bat on his roof for the earlier mentioned stuff."+    - Terawatt drops in to save the day. — "Terawatt drops in to save the day"+- **Riley** (key `riley`, 6 facts)+    - And then Grover finding the Blob and getting in danger. — "And then Grover finding the Blob and getting in danger."+    - Grover and Riley are fighting the Blob — "In the meantime Grover and Riley are fighting the Blob."+    - starting the assault on the silicates in New York — "Jack, Hanna, and Riley starting the assault on the silicates in New York"+    - Nice different pov here between Riley and Grover. — "Nice different pov here between Riley and Grover."+    - Of course, the best part of writing is likely the juxtaposition with a couple Charles ago when Alex was comparing herself to the others and these two show how they see her as the most dangerous being on the planet. — "Of course, the best part of writing is likely the juxtaposition with a couple Charles ago when Alex was comparing herself to the others and these two show how they see her as the most dangerous being on the planet."+    - Or rather trying to escape from it in the underground lab. — "Or rather trying to escape from it in the underground lab."+- **Graham** (key `graham`, 4 facts)+    - Graham is in danger of getting eaten. — "Graham is in danger of getting eaten."+    - Graham was saved. — "Graham was saved by Jo and another guy whose name escapes me right now."+    - Graham, Jo Lupo, and team arrived just in time to do American showboating and saving the day. — "In Tokyo things are getting better too with Graham, Jo Lupo, and team arriving just in time to do American showboating and saving the day."+    - Graham was saved again. — "Then Terawatt arrived and saved the day again."+- **Grover** (key `grover`, 4 facts)+    - And then Grover finding the Blob and getting in danger. — "And then Grover finding the Blob and getting in danger."+    - Grover and Riley are fighting the Blob — "In the meantime Grover and Riley are fighting the Blob."+    - Nice different pov here between Riley and Grover. — "Nice different pov here between Riley and Grover."+    - Or rather trying to escape from it in the underground lab. — "Or rather trying to escape from it in the underground lab."+- **Charlie** (key `charlie`, 4 facts)+    - Charlie is offered another home. — "And even another offer of a home for Charlie."+    - Charlie will become like Alex' sister/daughter. — "Anyway, this introduces Charlie, Char, the little kid who'll become like Alex' sister/daughter."+    - Charlie is offered many homes. — "Charlie is offered many homes and then a quick testimony at the Pentagon brings the Shop down."+    - Charlie is a kid. — "Oh Charlie. You poor kid."+- **Jack** (key `jack`, 4 facts)+    - Jack and even Terawatt try to take him too lightly in some ways. — "I feel like Jack and even Terawatt try to take him too lightly in some ways, or rather act too much like they know everything (comes back to the whole thing where she got years of knowledge in 5 days)."+    - starting the assault on the silicates in New York — "Jack, Hanna, and Riley starting the assault on the silicates in New York"+    - Jack got some cracks ribs. — "Jack, even though he got some cracks ribs and had to save Hanna quickly in turn."+    - Jack is trying to get everyone working well while doing his best to protect the kids. — "Literally in this case. There's also the usual self-deprecation of Alex and how she wishes she was as brave/strong as some of the others while she's even more so than them. Oh and as a bonus, this was the episode where the "handsome pilot" got his first mention. Long before we realise what that actually means."+- **Ron** (key `ron`, 3 facts)+    - Ron is there with Harry and Mike. — "Harry who is there with Ron and Mike"+    - Ron — "Harry, Hermione, and Ron in the Alexverse."+    - Harry Potter story obviously just from a spy community perspective. — "Very similar to the regular Harry Potter story obviously just from a spy community perspective."+- **Alex** (key `alex`, 3 facts)+    - Skyping with everyone. — "Alex Skyping with everyone (you can really date this stuff...)"+    - Alex is Charlie's sister/daughter. — "Anyway, this introduces Charlie, Char, the little kid who'll become like Alex' sister/daughter."+    - Alex changed back into Alex. — "It went well, and then Terawatt headed home again where she changed back into Alex and had a quick 3am meal with her parents."+- **Willow** (key `willow`, 3 facts)+    - Alex and Willow having fun. — "Alex and Willow having fun, with Alex talking down to the terrible person who badmouths Terawatt, and some more press pass shenanigans."+    - Willow got Alex a press pass. — "Willow got Alex a press pass so she gets special access to things."+    - She is doing her being way too good thing. — "Willow is doing her being way too good thing as well."+- **Harry** (key `harry`, 3 facts)+    - Harry is there with Ron and Mike. — "Harry who is there with Ron and Mike"+    - Harry — "Harry, Hermione, and Ron in the Alexverse."+    - Harry Potter story obviously just from a spy community perspective. — "Very similar to the regular Harry Potter story obviously just from a spy community perspective."+- **Blob** (key `blob`, 2 facts)+    - Grover and Riley are fighting the Blob — "In the meantime Grover and Riley are fighting the Blob."+    - Or rather trying to escape from it in the underground lab. — "Or rather trying to escape from it in the underground lab."+- **Hanna** (key `hanna`, 2 facts)+    - Hanna saved Jack. — "Hanna saved Jack, even though he got some cracks ribs and had to save Hanna quickly in turn."+    - starting the assault on the silicates in New York — "Jack, Hanna, and Riley starting the assault on the silicates in New York"+- **Hermione** (key `hermione`, 2 facts)+    - Hermione — "Harry, Hermione, and Ron in the Alexverse."+    - Harry Potter story obviously just from a spy community perspective. — "Very similar to the regular Harry Potter story obviously just from a spy community perspective."+- **Batman** (key `batman`, 2 facts)+    - Terawatt knows everything about him. — "Basically driving Batman crazy."+    - There's also been some more of the fight and aftermath. — "There's also been some more of the fight and aftermath, but that ended with Terawatt confronting the bat on his roof for the earlier mentioned stuff."+- **Hanna/Action Girl** (key `hanna/action girl`, 2 facts)+    - facing off against Bane — "Hanna/Action Girl facing off against Bane while Terawatt is going for Poison Ivy and both Bruce/Batman and Grover/Klar are dealing with the rest."+    - dealing with the rest — "both Bruce/Batman and Grover/Klar are dealing with the rest."+- **Janet** (key `janet`, 1 facts)+    - A doctor's visit with Janet. — "A doctor's visit with Janet."+- **general** (key `general`, 1 facts)+    - thinking he knows best — "an annoying general there thinking he knows best"+- **Bruce/Batman** (key `bruce/batman`, 1 facts)+    - dealing with the rest — "both Bruce/Batman and Grover/Klar are dealing with the rest."+- **Mike** (key `mike`, 1 facts)+    - Mike is there with Harry and Ron. — "Harry who is there with Ron and Mike"+- **Maggie Walsh** (key `maggie walsh`, 1 facts)+    - shows she's ruthless and doesn't care about what she does if it furthers her goals. — "Interlude where Maggie Walsh shows she's ruthless and doesn't care about what she does if it furthers her goals."+- **our tiny firecracker.** (key `our tiny firecracker.`, 1 facts)+    - Our tiny firecracker. — "Our tiny firecracker. 🔥"+- **Bane** (key `bane`, 1 facts)+    - facing off against Hanna/Action Girl — "Hanna/Action Girl facing off against Bane while Terawatt is going for Poison Ivy and both Bruce/Batman and Grover/Klar are dealing with the rest."+- **another overly good looking person** (key `another overly good looking person`, 1 facts)+    - might be involved with the bad guys — "might be involved with the bad guys"+- **Jo** (key `jo`, 1 facts)+    - Jo saved Graham. — "Graham was saved by Jo and another guy whose name escapes me right now."+- **Poison Ivy** (key `poison ivy`, 1 facts)+    - going for by Terawatt — "Terawatt is going for Poison Ivy and both Bruce/Batman and Grover/Klar are dealing with the rest."+- **Terrawatt** (key `terrawatt`, 1 facts)+    - answer on how to become a hero. — "Just an interlude about Terrawatt's answer on how to become a hero."+- **Mentors** (key `mentors`, 1 facts)+    - Mentors pick Alex up when needed — "aka when she needs to do superhero stuff"+- **Jo Lupo** (key `jo lupo`, 1 facts)+    - Graham, Jo Lupo, and team arrived just in time to do American showboating and saving the day. — "In Tokyo things are getting better too with Graham, Jo Lupo, and team arriving just in time to do American showboating and saving the day."+- **Bruce** (key `bruce`, 1 facts)+    - Bruce POV which includes an intro to Poison Ivy and Bane. — "Bruce POV which includes an intro to Poison Ivy and Bane."+- **illusionist robber** (key `illusionist robber`, 1 facts)+    - It was the short fight and aftermath with the illusionist robber. — "It was the short fight and aftermath with the illusionist robber and then some more standard stuff."+- **Grover/Klar** (key `grover/klar`, 1 facts)+    - dealing with the rest — "both Bruce/Batman and Grover/Klar are dealing with the rest."+- o'neill (name only)++Dropped candidates: 24 ["Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "monster-shaped good guy[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex\'s parents[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]", "Alex Mack[name not in source]"]+Dropped facts: 17 (17 non-verbatim)+Errors: 4 — ["refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))", "refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))"]++## Reborn as a Demonic Tree (10 sources)++- **Diana** (key `diana`, 6 facts)+    - And then the absolute mindfuck of what she's doing to them. — "And then the absolute mindfuck of what she's doing to them."+    - going to help Magnus — "Diana going to help Magnus"+    - Diana is in two places at the same time. — "Diana is in two places at the same time."+    - It wasn't as easy as Diana made it look in the last chapter. — "It wasn't as easy as Diana made it look in the last chapter."+    - Their reasons for coming, what they know about demons, and their fear once they realise what she is. — "Nice bit of different POV where we see the other side of Diana's battle with the two Empire Monarchs. Their reasons for coming, what they know about demons, and their fear once they realise what she is."+    - When he won, but had lost all of his Qi, she then took over his body with her mist. — "When he won, but had lost all of his Qi, she then took over his body with her mist."+- **Ashlock** (key `ashlock`, 5 facts)+    - didn't know Zephirine had lots of info — "Ashlock didn't know Zephirine had lots of info"+    - Ashlock seems to be calming down a bit and has the chance to look around. — "Ashlock seems to be calming down a bit and has the chance to look around."+    - starts to eat the corpses gathered by Douglas and Diana — "Ashlock starts to eat the corpses gathered by Douglas and Diana"+    - Getting surprised when he notices that Diana is in two places at the same time. — "Getting surprised when he notices that Diana is in two places at the same time."+    - seems like he might betray Stella out of envy again — "which is made even more clear when he comes into the crowned one's soul space as well. But he doesn't look like he's in control of his own body. Hoping he has some kind of plan, but also expecting it to fail and/or be a betrayal after all."+- **Janus** (key `janus`, 3 facts)+    - chatting with the mirrored one — "Janus who had a chat with the mirrored one"+    - sacrificed himself — "So in an over complicated plot, Janus sacrificed himself to basically kill the crowned one (or get him transferred into Ao, Stella's soul bound dragon)"+    - seems like he might betray Stella out of envy again — "which is made even more clear when he comes into the crowned one's soul space as well. But he doesn't look like he's in control of his own body. Hoping he has some kind of plan, but also expecting it to fail and/or be a betrayal after all."+- **Stella** (key `stella`, 3 facts)+    - has a soul bound dragon — "Stella's soul bound dragon"+    - driving the crowned one angry by not really caring what he says — "Stella, who is driving the crowned one angry by not really caring what he says"+    - seems like he might betray Stella out of envy again — "which is made even more clear when he comes into the crowned one's soul space as well. But he doesn't look like he's in control of his own body. Hoping he has some kind of plan, but also expecting it to fail and/or be a betrayal after all."+- **Zephirine** (key `zephirine`, 2 facts)+    - This summoned clone will be going with Zephirine to the empire. — "This summoned clone will be going with Zephirine to the empire."+    - she never volunteered it either — "she never volunteered it either"+- **The Crowned One** (key `the crowned one`, 2 facts)+    - driving the crowned one angry by not really caring what he says — "driving the crowned one angry by not really caring what he says"+    - seems like he might betray Stella out of envy again — "which is made even more clear when he comes into the crowned one's soul space as well. But he doesn't look like he's in control of his own body. Hoping he has some kind of plan, but also expecting it to fail and/or be a betrayal after all."+- **crowned one** (key `crowned one`, 2 facts)+    - was sacrificed by Janus — "So in an over complicated plot, Janus sacrificed himself to basically kill the crowned one (or get him transferred into Ao, Stella's soul bound dragon)"+    - was transferred into Ao — "or get him transferred into Ao, Stella's soul bound dragon"+- **Maple** (key `maple`, 2 facts)+    - Maple and Nyxalia in the meantime ended up killing a monarch who had already killed 2 of the defectors who joined Ashlock. — "Maple and Nyxalia in the meantime ended up killing a monarch who had already killed 2 of the defectors who joined Ashlock."+    - She immediately kills one. — "Maple and Nyxalia, who immediately kill one."+- **Nyxalia** (key `nyxalia`, 2 facts)+    - Maple and Nyxalia in the meantime ended up killing a monarch who had already killed 2 of the defectors who joined Ashlock. — "Maple and Nyxalia in the meantime ended up killing a monarch who had already killed 2 of the defectors who joined Ashlock."+    - She immediately kills one. — "Maple and Nyxalia, who immediately kill one."+- **defectors** (key `defectors`, 1 facts)+    - 2 of the defectors who joined Ashlock. — "2 of the defectors who joined Ashlock."+- **Empire Monarchs** (key `empire monarchs`, 1 facts)+    - Their reasons for coming, what they know about demons, and their fear once they realise what she is. — "Nice bit of different POV where we see the other side of Diana's battle with the two Empire Monarchs. Their reasons for coming, what they know about demons, and their fear once they realise what she is."+- **Ao** (key `ao`, 1 facts)+    - was transferred into by Janus — "or get him transferred into Ao, Stella's soul bound dragon"+- **Magnus** (key `magnus`, 1 facts)+    - facing a wind or air type monarch and getting his ass kicked — "Magnus who's facing a wind or air type monarch and getting his ass kicked"+- **world tree's root** (key `world tree's root`, 1 facts)+    - breaks through — "the world tree's root breaks through calling for Stella"+- **monarch** (key `monarch`, 1 facts)+    - A monarch who had already killed 2 of the defectors who joined Ashlock. — "A monarch who had already killed 2 of the defectors who joined Ashlock."+- douglas (name only)+- evaline (name only)+- mirrored one (name only)++Dropped candidates: 0 []+Dropped facts: 12 (12 non-verbatim)+Errors: 2 — ["refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))", "refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))"]++## Read What Happens When You Mess With the Original Novel (9 sources)++- **Sera** (key `sera`, 8 facts)+    - Her paintings get sold at the first showing. — "Her little artist is doing well and her paintings get sold at the first showing (including to the emperor, who she hadn't met before, and one to her new grandma)."+    - Her little artist is doing well. — "Just good things for Sera."+    - Lots of murderous thoughts here about the useless family that's coming out of the woodwork. — "Lots of murderous thoughts here about the useless family that's coming out of the woodwork."+    - Some sweet interactions between Sera and Orchis. — "Some sweet interactions between Sera and Orchis."+    - decides that she needs to get this resolved before the girl is killed. — "Talking to the councilman's daughter who is being stalked, Sera decides that she needs to get this resolved before the girl is killed."+    - made enemies by being outspoken and clearly against both corruption and stalkers. — "The council session. Sera made enemies by being outspoken and clearly against both corruption and stalkers."+- **stalker** (key `stalker`, 2 facts)+    - He is caught red-handed with a knife trying to actively hurt/kill the chairman's daughter. — "He is caught red-handed with a knife trying to actively hurt/kill the chairman's daughter."+    - passed the redevelopment law — "Then when both the stalker and redevelopment law were passed, the redevelopment one became impossible for the crown prince to abuse."+- **crown prince** (key `crown prince`, 2 facts)+    - didn't like her. — "The crown prince in particular didn't like her."+    - became impossible for the crown prince to abuse — "Then when both the stalker and redevelopment law were passed, the redevelopment one became impossible for the crown prince to abuse."+- **Batisa** (key `batisa`, 2 facts)+    - Batisa (the chairman's daughter) was happy and wants to go to Sera and Runie's retreat. — "Batisa (the chairman's daughter) was happy and wants to go to Sera and Runie's retreat."+    - Then we see what looks like a new character. Presumably the second prince. — "Then we see what looks like a new character. Presumably the second prince."+- **duchess Felikia** (key `duchess felikia`, 1 facts)+    - made the corrupt baron sign away some buildings from the redevelopment zone — "made the corrupt baron sign away some buildings from the redevelopment zone."+- **corrupt baron** (key `corrupt baron`, 1 facts)+    - made the corrupt baron sign away some buildings from the redevelopment zone — "made the corrupt baron sign away some buildings from the redevelopment zone."+- **redevelopment law** (key `redevelopment law`, 1 facts)+    - passed the redevelopment law — "Then when both the stalker and redevelopment law were passed, the redevelopment one became impossible for the crown prince to abuse."+- **Runie** (key `runie`, 1 facts)+    - Then we see what looks like a new character. Presumably the second prince. — "Then we see what looks like a new character. Presumably the second prince."+- **Kia** (key `kia`, 1 facts)+    - The viscount Kia visits Sera and after explaining all the bad things that happened to her son (the daughter in law was stalked and then the family killed off), she transfers all her rights and property to Sera. — "The viscount Kia visits Sera and after explaining all the bad things that happened to her son (the daughter in law was stalked and then the family killed off), she transfers all her rights and property to Sera."+- **Orchis** (key `orchis`, 1 facts)+    - Some sweet interactions between Sera and Orchis. — "Some sweet interactions between Sera and Orchis."+- chairman's daughter (name only)+- councilman's daughter (name only)+- emperor (name only)++Dropped candidates: 2 ["Sera\'s \'mother-in-law\'[name not in source]", "Sera\'s new grandma[name not in source]"]+Dropped facts: 10 (10 non-verbatim)+Errors: 2 — ["refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))", "refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))"]++## Read Solo Glitch Player (8 sources)++- **Jared** (key `jared`, 7 facts)+    - demon king follower in the original timeline — "Again, a demon king follower in the original timeline."+    - betterment of the province/Jared — "All for the betterment of the province/Jared."+    - finds a new vassal — "Forgot to finish this one yesterday. Basically, Jared finds a new vassal, this time a publicist/propaganda person who he wants to use for movies etc."+    - Jared is a character in the story. — "Jared and Haze needing to make their way through a room of basically trip lasers to reach the sleeping boss."+    - Jared knows of a glitch in the map that lets them go from early on to the last floor and abuses that. — "Jared knows of a glitch in the map that lets them go from early on to the last floor and abuses that."+    - shouldn't have thought of the church of Umbra as the likely culprit. — "Well, I'm glad at least that Jared wasn't doing the poisoning."+- **Isabelle** (key `isabelle`, 2 facts)+    - wants to start a guild for enchanters — "Couple of things happened here. Isabelle wants to start a guild for enchanters and gave up her share of that potions profit for it."+    - gave up her share of that potions profit for it — "Couple of things happened here. Isabelle wants to start a guild for enchanters and gave up her share of that potions profit for it."+- **Haze** (key `haze`, 1 facts)+    - Haze is a character in the story. — "Jared and Haze needing to make their way through a room of basically trip lasers to reach the sleeping boss."+- **Princess Myra** (key `princess myra`, 1 facts)+    - likely still wouldn't mind the whole wedding for better relations thing. — "Who didn't show it but likely still wouldn't mind the whole wedding for better relations thing."+- **everyone** (key `everyone`, 1 facts)+    - Everyone got their rewards and they left the dungeon. — "everyone got their rewards and they left the dungeon"+- **Terry** (key `terry`, 1 facts)+    - asked to look for dark mana users among the refugees — "In the meantime, refugees are arriving from the other kingdoms and they're settling them in a desolate place with the idea to turn it into a holy place. Terry (the kitten) is then asked to look for dark mana users among the refugees."+- **boss** (key `boss`, 1 facts)+    - Defeated the boss with a heal and deal thing that the boss slept through. — "Defeated the boss with a heal and deal thing that the boss slept through"+- **publicist/propaganda person** (key `publicist/propaganda person`, 1 facts)+    - vassal Jared wants to use for movies etc. — "Again, a demon king follower in the original timeline. It was a bit boring tbh."+- **Lena** (key `lena`, 1 facts)+    - increases her loyalty and lets her have an awakening — "increases her loyalty and lets her have an awakening"+- chloe (name only)+- loki's (name only)+- mia (name only)+- mid-boss (name only)++Dropped candidates: 0 []+Dropped facts: 5 (5 non-verbatim)+Errors: 1 — ["refusal(FoundationModels.LanguageModelSession.GenerationError.Refusal(record: FoundationModels.LanguageModelSession.GenerationError.Refusal.TranscriptRecord), FoundationModels.LanguageModelSession.GenerationError.Context(debugDescription: \"May contain sensitive content\", underlyingErrors: [], errorDescriptionOverride: nil))"]++## Read The Civil Servant of the Sword Returns (7 sources)++- **MC** (key `mc`, 14 facts)+    - He seems like he's not limited to skills from his class. — "Also seems like he's not limited to skills from his class as he chose to become a healer this time."+    - in the end is going to do the test tomorrow — "And in the end is going to do the test tomorrow"+    - trying to pretend he's new to all this — "Basically the MC trying to pretend he's new to all this while he's being interviewed"+    - He seems a bit overpowered for his level. — "He seems a bit overpowered for his level but that's returner MC power."+    - He's able to do a lot of the same sword skills etc. — "Interesting that after returning he's able to do a lot of the same sword skills etc."+    - MC comes out of the gate and shows that he's basically there to be the poster boy for doing good. — "MC comes out of the gate and shows that he's basically there to be the poster boy for doing good."+- **Healer** (key `healer`, 1 facts)+    - He chose to become a healer this time. — "Also seems like he's not limited to skills from his class as he chose to become a healer this time."+- **boss monster** (key `boss monster`, 1 facts)+    - now faces the boss monster — "now faces the boss monster"+- **leader** (key `leader`, 1 facts)+    - The MC used the leader for target practice. — "The MC basically just used the leader for target practice."+- **hobgoblins** (key `hobgoblins`, 1 facts)+    - MC is toying with the hobgoblins inside in order to gain skills. — "There he's now toying with the hobgoblins inside in order to gain skills."+- teammates (name only)++Dropped candidates: 0 []+Dropped facts: 5 (5 non-verbatim)++## System Change (System Universe) (7 sources)++- **Tanya** (key `tanya`, 7 facts)+    - She was previously #1. — "Also the tier III badge that Derek got seems to have a higher impact then implied before as even Tanya, who was previously #1 bowed to Derek."+    - Derek talking to Tanya. — "Derek talking to Tanya."+    - She wasn't entirely happy about Alex's ranking. — "His sister Tanya isn't entirely happy about that, but she was very happy anyway to see he's still alive."+    - Tanya is introduced as an interesting character — "It's not really much new, but it introduces Tanya as an interesting character and so far I like her."+    - Tanya asks questions and deduces some things from what Derek says — "Tanya asks questions and deduces some things from what Derek says and Alex also answers some questions too."+    - mentioned in the same chapter as Rook. — "This time to Rook, the father of Alex and Tanya."+- **Derek** (key `derek`, 7 facts)+    - He got a tier III badge. — "Also the tier III badge that Derek got seems to have a higher impact then implied before as even Tanya, who was previously #1 bowed to Derek."+    - At the end we see some young master figure on the planet who is pissed that someone killed the world boss and made it impossible. — "At the end we see some young master figure on the planet who is pissed that someone killed the world boss and made it impossible."+    - Derek talking to Tanya. — "Derek talking to Tanya."+    - Derek introduces Tanya as an interesting character — "It's not really much new, but it introduces Tanya as an interesting character and so far I like her."+    - Derek being his laid-back self — "Nice chapter. It's mostly just Derek being his laid-back self while Tanya asks questions and deduces some things from what Derek says and Alex also answers some questions too."+    - Not much going on with Derek. — "Not much going on with Derek."+- **Alex** (key `alex`, 4 facts)+    - Alex answers some questions — "Alex also answers some questions too."+    - He's still alive. — "Also the tier III badge that Derek got seems to have a higher impact then implied before as even Tanya, who was previously #1 bowed to Derek. Not that he likes that."+    - mentioned in the same chapter as Rook. — "This time to Rook, the father of Alex and Tanya."+    - son of Rook. — "father of Alex and Tanya."+- **Rook** (key `rook`, 3 facts)+    - a lot of the same stuff, but a bit more info on the Loomis clan and how things work. — "A lot of the same stuff, but a bit more info on the Loomis clan and how things work."+    - fill in Rook about Alex's achievements on the planet. — "Just a discussion where the fill in Rook about Alex's achievements on the planet."+    - father of Alex and Tanya. — "This time to Rook, the father of Alex and Tanya."+- **world boss** (key `world boss`, 1 facts)+    - Made it impossible. — "made it impossible."+- **Dave** (key `dave`, 1 facts)+    - Dave gave Derek the Velari excuse. — "Dave gave him and that was accepted easily."+- **Hallmaster Sheen** (key `hallmaster sheen`, 1 facts)+    - Hallmaster Sheen is actually a scary person. — "that Hallmaster Sheen is actually a scary person."++Dropped candidates: 0 []+Dropped facts: 4 (4 non-verbatim)++## Equivalence across runs (greedy)++- The Secret Return of Alex Mack: fact-triple Jaccard 1.00 (73/73), name-key Jaccard 1.00+- Reborn as a Demonic Tree: fact-triple Jaccard 1.00 (33/33), name-key Jaccard 1.00+- Read What Happens When You Mess With the Original Novel: fact-triple Jaccard 1.00 (20/20), name-key Jaccard 1.00+- Read Solo Glitch Player: fact-triple Jaccard 1.00 (15/15), name-key Jaccard 1.00+- Read The Civil Servant of the Sword Returns: fact-triple Jaccard 1.00 (18/18), name-key Jaccard 1.00+- System Change (System Universe): fact-triple Jaccard 1.00 (24/24), name-key Jaccard 1.00++## Timings++- Requests: 162, errors: 18+- Median 8.3s, p90 15.7s, max 23.5s
specs/character-extraction/requirements.md Added +124 / -0
diff --git a/specs/character-extraction/requirements.md b/specs/character-extraction/requirements.mdnew file mode 100644index 0000000..3233309--- /dev/null+++ b/specs/character-extraction/requirements.md@@ -0,0 +1,124 @@+# Requirements: Character Extraction++**Ticket:** T-2229 · **v2 plan item 4** (`docs/asterism-v2-plan.md`)++## Introduction++The on-device model reads a work's accumulated notes and proposes the+characters they mention — each fact carrying a verbatim quote from the note it+came from. The reader reviews the proposals, accepts what is worth keeping, and+can edit everything afterwards; accepted characters become a new `Character`+entity that survives export, import, and sync like any other reader data. The+entity's final field list is defined by a prototype pass over real notes during+design, not guessed in advance (design doc §2.2).++## Definitions++- **Source**: one entry's note, or the work's generic notes. The unit a fact cites and the unit of one model request.+- **Source revision**: the content of one source at a point in time, identified by a content fingerprint — editing a note *is* the invalidation of its old revision's coverage.+- **Candidate**: one proposed new character — a name, zero or more proposed facts — not yet decided.+- **Additional-facts bundle**: proposed facts — and, where a split name matched an existing character, proposed aliases — for a character the work already has.+- **Fact**: one discrete statement about a character, citing exactly one source and carrying an **evidence span**: a verbatim quote from that source's text. Fact identity is the triple (name key, source, evidence span).+- **Held proposal**: a candidate or bundle awaiting decision. Held proposals are device-local and never sync.+- **Decision**: accepting or skipping a candidate or bundle, or ticking/unticking an individual fact.+- **Covered**: a source revision is covered when the model request that processed it produced no proposals, or when every proposal derived from it is decided. Coverage is per revision, never per work or per pass.+- **Suppressed**: remembered so no automatic sweep re-proposes it — a skipped candidate's name key, or an unticked or deleted fact's identity triple.+- **Name key**: the normalised name a candidate or character is matched by; normalisation is locale-independent and identical across devices. Accepted characters retain the key they were proposed under; hand-created characters derive theirs from the typed name at creation commit and retain it thereafter.++## Non-Goals++- Spoiler boundaries or spoiler-aware filtering — the notes are the reader's own words; nothing here is a spoiler to them.+- Showing characters in the share sheet (T-1916, separate feature).+- Q&A over a work's notes (v2 plan item 5).+- Extraction anywhere in the share extension.+- Cross-work character linking (one character record belongs to one work).+- Update weekdays (v2 plan item 7) — it may share this feature's archive generation, but its behaviour is its own spec.+- Re-extraction that modifies or removes anything the reader accepted or edited.+- Writing model-proposed content to the library without explicit reader acceptance (decision and coverage bookkeeping are system records, not content).+- A setting to disable the automatic sweep — its bounds are the control.+- Contradiction detection across facts — notes contradict as stories develop; facts order by cited entry so it reads as history ([5.2](#5.2)).++## Requirements++### 1. Extraction proposals from a work's notes++**User Story:** As a reader, I want the app to work out who the characters are from my own chapter notes, so that I don't have to maintain a cast list by hand.++**Acceptance Criteria:**++1. <a name="1.1"></a>WHEN the app becomes active, the system SHALL sweep for works having uncovered source revisions and produce held proposals for them, prioritising works by most recent note activity and passing over torn works while the tear stands.+2. <a name="1.2"></a>The sweep SHALL NOT delay app activation, run in the share extension, or run during capture, and SHALL NOT run under Low Power Mode or elevated thermal state; it SHALL be bounded by named constants — works processed per activation (provisionally 2), works examined per activation (provisionally 100), per-attempt timeout (provisionally 10 s), and cumulative model-time budget per app run (provisionally 60 s) — values revisited after the design-phase prototype.+3. <a name="1.3"></a>Sweep model work SHALL be background-class: at most one model request at a time app-wide, never pre-empting and always pre-emptible by interactive model work (rule-suggestion editor requests, the manual pass); the same work SHALL NOT be processed twice concurrently.+4. <a name="1.4"></a>A model request SHALL contain only one source's text plus the work's display title — nothing else from the library and nothing from outside it; no library content SHALL leave the device.+5. <a name="1.5"></a>A source whose text exceeds what one model request can carry SHALL be skipped and left uncovered with the cause in diagnostics — never truncated and marked covered.+6. <a name="1.6"></a>Each fact SHALL cite exactly one source and carry an evidence span; the system SHALL discard, before showing, any fact whose cited source does not exist, does not belong to the work, or does not contain the evidence span verbatim, and any candidate whose name does not appear in the processed sources (case-insensitive). A candidate MAY carry zero facts.+7. <a name="1.7"></a>No pass — manual included — SHALL propose a fact whose identity triple matches an accepted fact; no automatic sweep SHALL propose suppressed content; a candidate or bundle left with no new content SHALL NOT be shown. Reader-visible behaviour SHALL NOT depend on the model reproducing identical output across runs.+8. <a name="1.8"></a>WHERE the model is unavailable or an automatic attempt fails, times out, or is refused by the model's safety guardrails, the sweep SHALL skip that source or work and continue, SHALL NOT retry it within the same app run, and the reader-visible behaviour SHALL be identical to no proposals existing, with the cause in diagnostics logging only.+9. <a name="1.9"></a>Diagnostics logging SHALL NOT expose note text, evidence spans, or proposal text in release builds (readable in Development builds only), matching the existing rule-suggestion logging convention.+10. <a name="1.10"></a>The number of candidates per pass, facts per candidate, and length of proposed names, facts, and evidence spans SHALL each be capped by a named constant.+11. <a name="1.11"></a>The reader SHALL be able to trigger an extraction pass for one work manually from that work's page; a manual pass SHALL process the work's sources regardless of coverage and suppression (the dedup of [1.7](#1.7) still applies), SHALL start even when the sweep budget is exhausted, SHALL never regress coverage, and SHALL end with a visible outcome — proposals ready, or "no proposals available" (covering empty, failed, and refused alike); the trigger SHALL NOT be offered while the model is unavailable.++### 2. Review and acceptance++**User Story:** As a reader, I want to approve which extracted characters and facts are kept, so that the model never writes to my library on its own.++**Acceptance Criteria:**++1. <a name="2.1"></a>WHEN held proposals exist for a work, the work's page SHALL show an indicator and offer a review list presenting each candidate or bundle with its name and proposed facts — a bundle alongside the target character's existing facts — facts ordered by cited entry's capture order (generic-notes facts first).+2. <a name="2.2"></a>The reader SHALL be able to accept or skip each candidate or bundle independently, to untick individual facts within one being accepted, and to strike a proposed alias before accepting; only accepted content SHALL be written, and a decision SHALL commit immediately, independent of the work page's edit mode. Skipping a *candidate* SHALL suppress the name keys displayed on its row at skip time — a struck alias's key is not among them; accepting a candidate clears the keys its row displayed.+3. <a name="2.3"></a>Whether a proposal matches an existing character SHALL be evaluated at decision time by name key, in a deterministic total order identical on every device: current-name match first, then retained-key match, then alias-key match, lowest character UUID within a tier.+4. <a name="2.4"></a>Skipping a candidate SHALL suppress its name keys ([2.2](#2.2)); skipping a bundle, or unticking or deleting a fact, SHALL suppress the affected facts' identity triples and SHALL NOT suppress any existing character's name key. A name-key suppression blocks new candidates only, never bundles for an existing character. Suppression is durable across restarts, synced across devices, and included in the backup archive; [1.11](#1.11) is the path back.+5. <a name="2.5"></a>Accepting a candidate — or committing a hand-created character ([3.2](#3.2)) — SHALL clear a standing suppression of its name key; accepting a ticked fact SHALL clear that fact's suppression.+6. <a name="2.6"></a>Dismissing the review list SHALL leave undecided proposals available for a later visit; sweep-produced proposals lost to a restart SHALL be re-derivable by a later sweep (their revisions are not yet covered); manual-pass proposals lost to a restart are gone — re-running manually is the path back.+7. <a name="2.7"></a>Staleness SHALL be per proposal: WHEN a proposal's cited revision changes while it is held, or a proposal displayed as a new character would resolve onto an existing character at commit, accepting it SHALL write nothing, disclose the staleness, and refresh the list; skipping it SHALL still record its suppression; other proposals in the list are unaffected, and an open list SHALL NOT change under the reader.+8. <a name="2.8"></a>WHEN the character a held bundle targets is deleted, the bundle SHALL be discarded. WHILE a work is torn, or a proposal resolves onto a torn character ([6.5](#6.5)) at decision time, *acceptance* SHALL be refused with the refusal disclosed as existing torn-record surfaces disclose it; skipping and unticking remain available ([2.7](#2.7) — they write only system records, [6.6](#6.6)).++### 3. The Character record++**User Story:** As a reader, I want accepted characters to be my data — editable, durable, and mine to create or delete — so that a model artefact never limits what I can keep.++**Acceptance Criteria:**++1. <a name="3.1"></a>A character — accepted or hand-created — SHALL belong to exactly one work and SHALL carry at minimum a reader-editable name, its facts with citations and evidence spans (empty for a hand-created one), a reader-editable free-text note, and its name key; any further fields are settled by the design following the prototype pass.+2. <a name="3.2"></a>The reader SHALL be able to edit a character's name, note, and fact texts, to delete a character or an individual fact, and to create a character by hand; reader edits and creations SHALL survive extraction passes untouched.+3. <a name="3.3"></a>WHEN the reader deletes a character (a combine's absorption of its source is not a deletion), its name keys — retained, current, and alias — and deleted facts' identity triples SHALL be suppressed per [2.4](#2.4); a fact-triple suppression covers every stored copy of that triple.+4. <a name="3.4"></a>WHEN a work is deleted, its characters, held proposals, suppressions, and coverage SHALL be deleted with it; WHEN a work is merged into another, its characters SHALL move to the merge target — entry citations intact, generic-notes citations repointed to the target's generic notes — suppressions SHALL be unioned, held proposals discarded, and the target's coverage reset so a later sweep revisits it.+5. <a name="3.5"></a>A fact whose cited source no longer exists SHALL keep its text and evidence span, display without navigation, and never be treated as an integrity error; evidence spans are verified at proposal time only ([1.6](#1.6)), never re-verified against later source text.+6. <a name="3.6"></a>WHEN duplicate entries are collapsed, citations and suppression keys referencing the removed row SHALL follow the surviving row.+7. <a name="3.7"></a>The reader SHALL be able to combine two of a work's characters into one: the target keeps its name and identity; the source's name, aliases, and retained key become alias keys of the target; the source's facts move to the target (both copies kept where the same fact's statements were edited apart); the source's active fact suppressions follow to the target's key; the source's note is appended to the target's under a divider; nothing is silently lost *by the combine itself*. A combine SHALL NOT write new suppressions — a later proposal matching any absorbed name SHALL route to the combined character as additional facts.++### 4. Updates as notes accumulate++**User Story:** As a reader, I want new chapters' notes to enrich the cast over time, so that the character list stays current without my re-running anything.++**Acceptance Criteria:**++1. <a name="4.1"></a>WHEN uncovered source revisions exist (new notes, edited notes), a later sweep SHALL propose only additions — new candidates, or bundles for existing characters — for the same review flow, with newness judged by fact identity ([1.7](#1.7)), not wording.+2. <a name="4.2"></a>An extraction pass SHALL NOT modify, remove, or re-word any accepted character, accepted fact, or reader edit; an accepted fact SHALL keep its citation and evidence span even after the cited note is later edited.+3. <a name="4.3"></a>WHEN a sweep finds nothing new for a work, the reader-visible behaviour SHALL be identical to no sweep having run.++### 5. Display++**User Story:** As a reader, I want to see a work's characters where I read about the work, so that I can refresh my memory of who is who.++**Acceptance Criteria:**++1. <a name="5.1"></a>The work page (view mode) SHALL show the work's characters; a work with no characters and no held proposals SHALL show nothing character-related.+2. <a name="5.2"></a>A character's facts SHALL display ordered by cited entry's capture order, each citation navigable to the cited entry (or to the work's notes for a generic-notes citation) WHERE the source still exists ([3.5](#3.5) otherwise).+3. <a name="5.3"></a>Character editing, deletion, manual creation ([3.2](#3.2)), and combining ([3.7](#3.7)) SHALL live in the work page's existing edit mode, following its commit/discard semantics, including the existing torn-work read-only gate.+4. <a name="5.4"></a>An entry's detail SHALL show which characters have facts citing that entry, WHERE any do.++### 6. Durability, sync, and integrity++**User Story:** As a reader, I want characters to survive backup, restore, and sync exactly like my notes do, so that accepting them is not a data-loss risk.++**Acceptance Criteria:**++1. <a name="6.1"></a>Characters (facts, citations, evidence spans, name keys), suppressions, and coverage SHALL round-trip through the full-library backup export and the UUID-keyed import upsert; importing an archive from before this feature SHALL succeed with no characters created; the new archive generation SHALL remain importable alongside the currently accepted generations, and an archive of the new generation being unreadable by pre-feature builds is accepted.+2. <a name="6.2"></a>The backup export SHALL succeed while a fact's citation dangles ([3.5](#3.5) is a tolerated state, not corruption).+3. <a name="6.3"></a>Characters SHALL sync across devices through the existing CloudKit mirroring, respecting its constraints (no uniqueness enforcement, optional-or-defaulted properties); pre-feature builds sharing the container SHALL continue to sync existing record types unaffected.+4. <a name="6.4"></a>Two rows sharing one character UUID SHALL converge in place like other same-UUID records; distinct-UUID duplicates of the same character are the reader's to combine ([3.7](#3.7)), edit, or delete — never auto-resolved and never auto-merged.+5. <a name="6.5"></a>WHERE copies of a character diverge in reader-authored content, the divergence SHALL be handled as existing torn records are: disclosed to the reader, resolved by choice not by silent merge, and refused by the backup exporter while torn.+6. <a name="6.6"></a>Suppression and coverage records are system records, never reader-authored: they SHALL never tear and never block an export, and SHALL converge across devices to the reader's most recent action — a clear ([2.5](#2.5)) SHALL NOT be undone by an older suppression syncing in.+7. <a name="6.7"></a>A character, fact, or citation arriving by sync before its work or entry SHALL be a tolerated in-flight state with defined display degradation, never data loss or a crash.+8. <a name="6.8"></a>A store from the previous schema version SHALL migrate forward with all existing data intact and zero characters; captures shared during the update window SHALL survive via the existing pending-capture queue.
specs/character-extraction/tasks.md Added +191 / -0
diff --git a/specs/character-extraction/tasks.md b/specs/character-extraction/tasks.mdnew file mode 100644index 0000000..d5c49d6--- /dev/null+++ b/specs/character-extraction/tasks.md@@ -0,0 +1,191 @@+---+references:+    - specs/character-extraction/requirements.md+    - specs/character-extraction/design.md+    - specs/character-extraction/decision_log.md+---+# Character Extraction (T-2229)++## Store and schema++- [x] 1. Write failing migration and bootstrap tests for schema V7 <!-- id:p0u8p2y -->+  - V5→V7 and V6→V7 conversion, V4 refusal, marker "7", new lagging-V6 BootstrapState case; successor fixture seeds through the frozen V6 snapshot+  - Suites to mirror: V5RecordedStoreTests, BootstrapStateCoverage/Classifier/Action; plan stays [V5, V6, V7] (Q80)+  - Stream: 1+  - Requirements: [6.8](requirements.md#6.8)++- [x] 2. Freeze V6, add AsterismSchemaV7 with Character, CharacterSuppression, and coverage fields; extend migration plan and bootstrap <!-- id:p0u8p2z -->+  - Follow docs/agent-notes/schema-migration.md six-row checklist+  - Character/CharacterSuppression per design Data model; Entry.characterExtractionFingerprint and Work.genericNotesExtractionFingerprint excluded from authored content (Q59)+  - CloudKit-safe: defaulted/optional, no uniques, inverse on Work.characters+  - Blocked-by: p0u8p2y (Write failing migration and bootstrap tests for schema V7)+  - Stream: 1+  - Requirements: [6.3](requirements.md#6.3), [6.8](requirements.md#6.8)++- [x] 3. Write failing tests for CharacterFact canonical encoding and name-key normalisation <!-- id:p0u8p30 -->+  - Key recipe: trim → NFC → locale-free case fold (WorkTypeName.normalize) → strip one leading "the " (Q41/Q64); Turkish-I stability+  - Canonical encoding sorted-keys JSON, facts ordered (source, quote, statement) — total under edited-apart copies (Q75/Q98)+  - Stream: 1+  - Requirements: [1.6](requirements.md#1.6)++- [x] 4. Implement CharacterFact, SourceRef, name-key normalisation, and the canonical facts encoder <!-- id:p0u8p31 -->+  - In AsterismCore; CharacterFact carries statement, immutable quote, nameKey (Q79), SourceRef .entry(UUID)/.genericNotes+  - Blocked-by: p0u8p30 (Write failing tests for CharacterFact canonical encoding and name-key normalisation)+  - Stream: 1+  - Requirements: [1.6](requirements.md#1.6)++- [x] 5. Write failing duplicate-machinery tests for Character (UUID-only sets, convergence, torn variants, repoints) <!-- id:p0u8p32 -->+  - UUID-only buckets — .merge unreachable (Q76); CharacterAuthoredContent never bare+  - ledger.retain covers character sets; DeletionRows equality filters; workload joins UUID-grouped only+  - Collapse repoints citations + suppression sourceEntryID with group-wide factsData rewrite so no false tear (Q85)+  - Blocked-by: p0u8p2z (Freeze V6, add AsterismSchemaV7 with Character, CharacterSuppression, and coverage fields; extend migration plan and bootstrap), p0u8p31 (Implement CharacterFact, SourceRef, name-key normalisation, and the canonical facts encoder)+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5)++- [x] 6. Implement DuplicateRecordType.character across scan, reconciler, workload, and collapse repointing <!-- id:p0u8p33 -->+  - Sites: both audit tables in design.md §Duplicate/torn — DuplicateScan.swift:17 (case appended last), resolve/contract switches, CollapsedRecordType ternary + switch, stage + DeletionRows, DuplicateWorkload, BackupGroupProjection character groups+  - Blocked-by: p0u8p32 (Write failing duplicate-machinery tests for Character)+  - Stream: 1+  - Requirements: [3.6](requirements.md#3.6), [6.4](requirements.md#6.4), [6.5](requirements.md#6.5)++- [x] 7. Write failing repository tests for extraction candidates read and decision commits <!-- id:p0u8p34 -->+  - One locked context read: fingerprints, coverage, accepted fact keys, active suppressions (Q78); recency incl. Work.modifiedAt for generic notes (Q71/Q84); torn works excluded (Q53)+  - Commit: accept/skip/untick/bundle × stale fingerprint / re-routed match incl. new-candidate-onto-existing / torn / clean; displayed-target verification (Q66)+  - Coverage advance incl. produced-none and manual-pass writes (Q65); suppression LWW update-in-place, cleared-wins then lowest-UUID tie-break (Q82); clears on accept incl. displayed-key sets (2.2/2.5); sync-orphan tolerance (6.7)+  - Blocked-by: p0u8p2z (Freeze V6, add AsterismSchemaV7 with Character, CharacterSuppression, and coverage fields; extend migration plan and bootstrap), p0u8p31 (Implement CharacterFact, SourceRef, name-key normalisation, and the canonical facts encoder)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [6.6](requirements.md#6.6), [6.7](requirements.md#6.7)++- [x] 8. Implement characterExtractionCandidates and commitCharacterDecision <!-- id:p0u8p35 -->+  - New LibraryRepository+CharacterExtraction.swift; decisions commit one save each, independent of edit mode (Q37)+  - Blocked-by: p0u8p34 (Write failing repository tests for extraction candidates read and decision commits)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [2.6](requirements.md#2.6), [2.7](requirements.md#2.7), [2.8](requirements.md#2.8), [6.6](requirements.md#6.6)++- [x] 9. Write failing tests for the character edit-step call, combine, work merge/delete integration, and EntryTeachingDetail <!-- id:p0u8p36 -->+  - Edit step slots between metadata save and close in commitEditing (WorkDetailModel.swift:323-338 shape); staged ops in performed order, per-character CharacterEditBasis, whole-step refusal (Q73/Q97)+  - Combine: alias union incl. bare retained key deduped vs target keys (Q91), fact re-key + edited-apart survival (Q94), fact-suppression re-key, group fan-out + whole-group source delete (WorkMerge :274-276/:300-303 rule), no new suppressions, 2.8 held-proposal discard+  - Deletion suppresses retained+current+alias keys (Q50); merge: repointCharacters, contract counts, generic-notes citation repoint, coverage reset+  - EntryTeachingDetail gains citing-characters field populated in the single locked context+  - Blocked-by: p0u8p33 (Implement DuplicateRecordType.character across scan, reconciler, workload, and collapse repointing), p0u8p35 (Implement characterExtractionCandidates and commitCharacterDecision)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.7](requirements.md#3.7), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4)++- [x] 10. Implement the character edit-step repository call, combine, merge/delete integration, and EntryTeachingDetail field <!-- id:p0u8p37 -->+  - Blocked-by: p0u8p36 (Write failing tests for the character edit-step call, combine, work merge/delete integration, and EntryTeachingDetail)+  - Stream: 1+  - Requirements: [3.1](requirements.md#3.1), [3.2](requirements.md#3.2), [3.3](requirements.md#3.3), [3.4](requirements.md#3.4), [3.5](requirements.md#3.5), [3.7](requirements.md#3.7)++- [x] 11. Write failing archive 6/7 tests <!-- id:p0u8p38 -->+  - Round-trip incl. suppressions + self-validating coverage (Q81); orphan character exports with nil work ref, optional-checked-when-present validation (validateEntry pattern)+  - Dangling-citation exemption pin (Decision 2); torn refusal; pre-feature archive import → zero characters; idempotent re-import; importer accepts 4/4, 5/6, 6/7+  - Blocked-by: p0u8p2z (Freeze V6, add AsterismSchemaV7 with Character, CharacterSuppression, and coverage fields; extend migration plan and bootstrap), p0u8p31 (Implement CharacterFact, SourceRef, name-key normalisation, and the canonical facts encoder), p0u8p33 (Implement DuplicateRecordType.character across scan, reconciler, workload, and collapse repointing)+  - Stream: 1+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2)++- [x] 12. Implement BackupV6 types, codec, exporter, importer, and group-projection extension <!-- id:p0u8p39 -->+  - Blocked-by: p0u8p38 (Write failing archive 6/7 tests)+  - Stream: 1+  - Requirements: [6.1](requirements.md#6.1), [6.2](requirements.md#6.2)++## Intelligence++- [x] 13. Write failing ModelLane tests including rule-suggestion adoption regressions <!-- id:p0u8p3a -->+  - Contention matrix: interactive pre-empts background; cross-feature FIFO; feature-internal swap keeps slot (rule .request over .open unchanged — RuleSuggestionLedger.swift:251-269)+  - Token release on cancel/deinit (no stranded slot); background-vs-background serialisation; lane wait is not a settlement (no budget, no attempt memory)+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3)++- [x] 14. Implement ModelLane and adopt it in the rule-suggestion pipeline <!-- id:p0u8p3b -->+  - actor ModelLane in AsterismIntelligence; RuleSuggester/coordinator adopt (.open/.request → interactive, sweep → background)+  - Observable rule-suggestion behaviour unchanged except the documented manual-pass auto-apply caveat (Q68)+  - Blocked-by: p0u8p3a (Write failing ModelLane tests including rule-suggestion adoption regressions)+  - Stream: 2+  - Requirements: [1.3](requirements.md#1.3)++- [x] 15. Write failing grounding and assembly tests (property-style generators) <!-- id:p0u8p3c -->+  - Generators over (note, output): quotes verbatim substrings, no absent names, caps hold, resolved-key canonicalisation dedups alias spellings (Q79), one candidate per name key per pass (Q83)+  - Slash-split: multi-slash, non-grounding component cancels split, proposed aliases carried on bundles (Q90/Q96), name-half-only matching (Q93)+  - Stream: 2+  - Requirements: [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [2.3](requirements.md#2.3)++- [x] 16. Implement extraction DTOs, model clients (Foundation + stub), grounding, assembly, and bounds <!-- id:p0u8p3d -->+  - Mirror RuleProposal/RuleSuggestionModelClient/Stub+Recorder patterns; prompt asks named story characters only (Q55); greedy sampling+  - Live-call test nested withKnownIssue; CharacterExtractionBounds per Q54 (30 s attempt, 120 s run budget, 2 works, 100 examined, output caps)+  - Blocked-by: p0u8p3c (Write failing grounding and assembly tests)+  - Stream: 2+  - Requirements: [1.4](requirements.md#1.4), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.9](requirements.md#1.9), [1.10](requirements.md#1.10)++- [x] 17. Write failing extraction-ledger tests <!-- id:p0u8p3e -->+  - Held per work, attempted sources (not on cancellation — Q69), budget on every settlement, sweep generations+  - Reconcile invalidation for deleted work/character and changed corpus (2.8); memoryWarning/resignActive+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.8](requirements.md#1.8), [2.6](requirements.md#2.6)++- [x] 18. Implement CharacterExtractionLedger <!-- id:p0u8p3f -->+  - Blocked-by: p0u8p3e (Write failing extraction-ledger tests)+  - Stream: 2+  - Requirements: [1.2](requirements.md#1.2), [1.8](requirements.md#1.8), [2.6](requirements.md#2.6)++## App integration++- [x] 19. Write failing coordinator tests against the stub client <!-- id:p0u8p3g -->+  - Sweep bounds and gates (Low Power/thermal, active), produced-none coverage at pass time+  - Manual pass ignores coverage/suppression but dedups accepted facts, never regresses coverage, exempt from budget, visible outcomes, hidden trigger when unavailable+  - Blocked-by: p0u8p35 (Implement characterExtractionCandidates and commitCharacterDecision), p0u8p3b (Implement ModelLane and adopt it in the rule-suggestion pipeline), p0u8p3d (Implement extraction DTOs, model clients, grounding, assembly, and bounds), p0u8p3f (Implement CharacterExtractionLedger)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.11](requirements.md#1.11), [4.3](requirements.md#4.3)++- [x] 20. Implement CharacterExtractionCoordinator, app wiring, diagnostics, and UI-test stub injection <!-- id:p0u8p3h -->+  - Wire beside rule sweep (AppLibraryModel.swift:395-397) and reconcile hook (:605); ContentView resignActive/memoryWarning+  - Log category CharacterExtraction beside RuleSuggestion, content readable in Development only (Q23); UITestLaunchSupport stub injection+  - Blocked-by: p0u8p3g (Write failing coordinator tests against the stub client)+  - Stream: 1+  - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.9](requirements.md#1.9), [1.11](requirements.md#1.11)++- [x] 21. Write failing review-sheet model tests <!-- id:p0u8p3i -->+  - Snapshot-at-open; per-candidate accept/skip, per-fact ticks, alias strikes (Q92); displayed-key suppression on skip+  - Refusal→refresh re-presents candidate as bundle; undecided survive dismissal+  - Blocked-by: p0u8p37 (Implement the character edit-step repository call, combine, merge/delete integration, and EntryTeachingDetail field), p0u8p3h (Implement CharacterExtractionCoordinator, app wiring, diagnostics, and UI-test stub injection)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.5](requirements.md#2.5), [2.7](requirements.md#2.7)++- [x] 22. Implement the review sheet (model + view) <!-- id:p0u8p3j -->+  - PostTeachingWorkURLModel queue precedent; sparkles suggested badge (ComposedTeachingView.swift:236-246); bundle rows show target's existing facts (Q38)+  - Blocked-by: p0u8p3i (Write failing review-sheet model tests)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.5](requirements.md#2.5), [2.7](requirements.md#2.7)++- [x] 23. Write failing tests for work-page character models, entry-detail section, and duplicate-resolution arm <!-- id:p0u8p3k -->+  - WorkDetailModel character drafts captured before save()'s load(); combine staged and discardable (Q97)+  - DuplicateResolutionModel characterVariants; RecentView:677 / MaintenanceViewModels:280 label arms+  - Blocked-by: p0u8p37 (Implement the character edit-step repository call, combine, merge/delete integration, and EntryTeachingDetail field), p0u8p3h (Implement CharacterExtractionCoordinator, app wiring, diagnostics, and UI-test stub injection)+  - Stream: 1+  - Requirements: [3.2](requirements.md#3.2), [3.7](requirements.md#3.7), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [6.5](requirements.md#6.5)++- [x] 24. Implement work-page character UI, entry-detail section, and duplicate-resolution character arm <!-- id:p0u8p3l -->+  - Indicator: duplicateReviewSection card register at WorkDetailView top slot (line 99); characters section between viewNotesSection and chapterSection (chapterSection idiom, fact order Q88)+  - Edit-mode create/edit/delete/combine incl. torn read-only gate; manual-pass trigger suggestRow pattern; entry-detail section between actions and capture details+  - Blocked-by: p0u8p3k (Write failing tests for work-page character models, entry-detail section, and duplicate-resolution arm)+  - Stream: 1+  - Requirements: [3.2](requirements.md#3.2), [3.7](requirements.md#3.7), [5.1](requirements.md#5.1), [5.2](requirements.md#5.2), [5.3](requirements.md#5.3), [5.4](requirements.md#5.4), [6.5](requirements.md#6.5)++- [x] 25. Write end-to-end stub-driven UI tests for the extraction flow <!-- id:p0u8p3m -->+  - Stub-driven: indicator → review (ticks, strikes, skip) → accept → work-page section → entry-detail citations; manual-pass outcome states; no live model in UI tests+  - Blocked-by: p0u8p3j (Implement the review sheet), p0u8p3l (Implement work-page character UI, entry-detail section, and duplicate-resolution character arm)+  - Stream: 1+  - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [5.1](requirements.md#5.1), [5.4](requirements.md#5.4)++- [x] 26. Write failing tests for the Settings backup surface exporting archive generation 6/7 <!-- id:p0u8p3n -->+  - AppLibraryModel.settingsBackupModel() (~line 1336) constructs BackupV5Exporter; SettingsBackupModel's BackupExporting seam is typed on BackupV5Metadata/BackupV5ExportError with a message arm per case+  - Suites to update: SettingsBackupModelTests and IntegrationSafetyNetTests+  - Gap found at phase-1 review: repository and codec reach 6/7 but the Settings surface still exports 5/6; Req 6.1's round-trip is not reachable end to end+  - Blocked-by: p0u8p39 (Implement BackupV6 types, codec, exporter, importer, and group-projection extension)+  - Stream: 1+  - Requirements: [6.1](requirements.md#6.1)++- [x] 27. Switch the Settings backup surface to BackupV6Exporter <!-- id:p0u8p3o -->+  - Retype the BackupExporting seam on BackupV6Metadata/BackupV6ExportError with a message arm per case incl. the torn-character refusal; keep pre-feature import acceptance untouched+  - Blocked-by: p0u8p3n (Write failing tests for the Settings backup surface exporting archive generation 6/7)+  - Stream: 1+  - Requirements: [6.1](requirements.md#6.1)

Things to double-check

Q86 CloudKit dev-schema publication.

You installed the Development build; now launch it once and let it sync so the two new record types publish to the dev container before any second device syncs. Still unchecked in prerequisites.md, along with a fresh Personal backup before updating the daily-use install.

Perf budget.

Characters in the Req 10.1 path are unmeasured. Run make test-performance-m4 (~20 min, host-only, safe) once before the next release cut, and treat a single run as indicative only.

Capture-order display change.

The review sheet's fact order changed from entry-UUID order to capture order for merged rows — worth an eyeball on a real multi-entry candidate.

Concurrent-session archive semantics.

Torn-character export refusal now applies on the 4/4 and 5/6 paths too (Q105) — deliberate, but it is a behaviour change to shipped formats; the 4/4 leg is pinned only indirectly.