asterism branch T-2276/place-extraction commits 40 files 178 touched lines +15,522 / -5,370

Pre-push review: T-2276/place-extraction

Place extraction: places as a second record kind, from schema V13 through the review sheet, over 40 unpushed commits.

At a glance

  • Places become a second record kind beside characters: schema V13 with Place and PlaceSuppression, marker "13", archive 12/13, and the character store code made generic over a RecordRow protocol.

  • One model request returns both kinds; the review sheet decides both with a per-row Character/Place switch, and a kept record converts between kinds in the editor as a delete-and-recreate staged on Save.

  • Every phase was design-critic reviewed during implementation (Q63 to Q83); this review added Q84 to Q86, one major efficiency fix on the reconciler, and twenty smaller fixes. Eight suggestions are deferred with reasons.

  • Host verification: 41 M4 arms exit 0 with the same nine known issues; the place ranking arm is 3.5 ms under a 10 ms budget. The phone timing gate (Req 6.1) and the CloudKit publication run are owner steps still open.

Verdict

Ready to push

Ready to push. Every requirement except the owner-run phone gate is met and named in implementation.md; the four review agents raised one major finding, fixed here, and nothing else above minor. The unit and package suites are green after the fixes. Task 26 remains a release gate, not a push gate: it needs the phone and two taps, and its numbers go into the verification file afterwards.

Review findings

29 raised · 21 fixed · 8 skipped

Jump to findings →

Tests

Pass rate: 100% (2700 of 2700)

New tests: 173

Diff coverage: 97% (6431 of 6624 added lines)

Jump to tests →

Commits

Three-level explanation

What Changed

Asterism already reads a reader's chapter notes with Apple's on-device model and proposes the *characters* those notes name. The reader reviews each proposal and either keeps it or skips it. This branch teaches the same pass to propose *places* as well, and gives places the same life a character has: a name, alternative names, a free-text note, cited facts, an editor, and a section on the work page.

Four things happen for the reader:

1. One pass, two kinds. The single request the app sends the model for each note now asks for characters and places together, so nothing gets slower and no second pass appears. 2. One review list. Characters and places are reviewed in the same sitting, each row labelled with what it is. 3. A correction the reader can make. The model frequently files a place as a character. A row now carries a Character/Place switch, so the reader can re-file a proposal before keeping it. 4. A rescue for records already kept under the wrong kind. Inside the work editor there is a new action, "Make this a place" (or "Make this a character"), that moves a record across with its facts, citations and note intact.

Underneath that, the app's database grew two new tables, the backup file format moved a generation, and about two thousand lines of character-specific storage code were rewritten once, generically, so that places reuse it rather than copying it.

Why It Matters

The reader has been keeping extracted characters since August, and a good number of the things they kept are places. Deleting a mis-filed character and typing a place by hand would throw away the facts, the quotes and the links back to the notes those facts came from, which is the entire value of the record. The conversion action keeps all of it.

The shared model request matters for a different reason. On-device model time is the scarce resource in this app, and the reader waits for it. A second pass for places would have doubled that wait. Asking one question that returns two answers costs almost nothing extra, which is what Requirement 6.1 exists to prove on real hardware.

Key Concepts

Schema. The shape of the app's local database: which tables exist and what columns each has. Think of it as the blueprint of a filing cabinet. This branch moves the blueprint from version 12 (V12) to version 13 (V13) by adding two drawers, Place and PlaceSuppression.

Migration. Rebuilding an existing filing cabinet to a new blueprint without losing what is in it. Because V13 only *adds* drawers and changes nothing existing, the migration is what Apple calls "lightweight": the system handles it with no custom code, and every existing row is untouched.

Readiness marker. A tiny file the app writes saying which blueprint the cabinet is on. The share extension (the thing that catches a page you share into Asterism) reads that marker and refuses to open a cabinet it does not understand, which is what keeps it from ever attempting a migration of its own. The marker moved from "12" to "13".

CloudKit sync. Apple's service that copies the database between the reader's devices. It is not a backup and there is no custom sync engine; the app hands SwiftData a container and SwiftData mirrors rows.

Name key. A normalised, lower-cased form of a record's name, used for matching. "Terawatt", "terawatt" and " Terawatt " all share one key. The key is minted once when a record is created and is then *retained*: renaming the record does not change it, so a proposal the model makes later still finds the record.

Suppression. A remembered "no". When the reader skips a proposal, the app writes a row saying "do not propose this name for this work again". Suppressions are per kind here: skipping the place "Bay" must not silence the character "Bay". That is why places got their own suppression table rather than a flag on the existing one.

Convergence. The repair mode for sync. Two devices can end up holding two database rows carrying the same record UUID. Convergence makes those rows identical in place and deletes nothing. Where the two rows disagree about something the reader wrote, the record is "torn" and the reader is asked which version to keep.

Orphan. A place whose owning work cannot be found, usually because the place synced in before the work did. A place names its work by UUID rather than by a database relationship, so an unresolvable owner is a dangling number rather than a broken link. Orphans are tolerated, displayed nowhere, exported as they are, and never deleted by a sweep. The moment the work arrives, the place appears under it.

Changes Overview

Thirty-nine commits, 176 files, in five phases, each phase a run of red/green pairs: a commit of failing tests, then the commit that makes them pass. Red commits are labelled as such in their messages and are allowed not to compile against the green half's types, because the branch squash-merges (Q75).

Three layers took three different treatments.

*Store (AsterismCore).* A new protocol RecordRow in RecordRow.swift abstracts a named record with cited facts, and SuppressionRow does the same for the two suppression tables. CharacterRecord, CharacterSuppression, Place and PlaceSuppression conform. Everything mechanical over the tuple (name, key, aliases, note, facts, work) became generic: RecordGroup<Row> (RecordGroups.swift), RecordFact/RecordFactCodec (RecordFacts.swift), CitationRepointing, RecordRanking, WorkRecordPresentation, the decision commit in LibraryRepository+RecordExtraction.swift, the edit commit in LibraryRepository+RecordEditing.swift, and the archive merge in BackupImportRecords.swift. Old names survive as typealiases where call sites read better for it.

*Pipeline (AsterismIntelligence) and coordinator (app).* Not generic. A RecordKind field threads through ExtractionResult, GroundedCandidate, ExtractionProposal, ProposalKey, DecisionRequest and the ledger. The type names keep their Character… prefix, because there is one pipeline and renaming it would buy churn (Q40).

*Views.* Parameterised by kind through one copy table, RecordKindPresentation.swift. New elements exist only where the feature is new: the review row's kind control, the cross-kind hint, and the editor's convert action.

Implementation Approach

Why generic over a protocol rather than a kind switch or a copy. Decision 3. The store code is identical for both kinds and a copy would be two thousand lines kept in step by hand. A switch kind in the repository spreads into every function that touches a row. The one real constraint on generic SwiftData is that a #Predicate cannot be written against a protocol-typed key path, so generic code writes none: it calls Row.rows(of:context:) or Row.rows(ids:context:) and each conformance answers the way its own table must be read. Characters walk their inverse relationship; Place runs one #Predicate { ids.contains($0.workID) } fetch for every work handed in and groups in memory. The shape was proved in a standalone package (prototype/generic-store-spike) before the design was accepted (Q58), which is also where the recordID naming came from: PersistentModel already vends id: PersistentIdentifier, so a generic row.id is ambiguous (Q52).

Why UUID columns rather than relationships. Place.workID and PlaceSuppression.workID are non-optional defaulted UUID columns, the WorkCredit shape (Q44). This is CLAUDE.md's standing rule, now made a fourth time. An inverse faults every row on the other side, and a .nullify on an absent target would erase an owner that must survive as unresolved while the work is still in transit. The cost is that orphan-ness means "no work resolves" rather than "the link is nil", which the protocol states explicitly through ownerWorkID.

Why a separate suppression table. Decision 2, and it is a sync argument, not a storage one. Both configurations mirror to CloudKit, and during an update window a pre-feature build shares the container. To such a build, a place suppression carrying a hidden record-kind column is an ordinary character suppression with the same tuple, so accepting a character "Bay" there would *clear* a place suppression it cannot see. That is an unauthorised write. A record type a build does not know about is ignored entirely.

The dual-kind rule. One response can return the same name under both kinds. CharacterExtractionAssembler.applyDualKindRule runs after per-kind filtering, over the survivors of *one source response* only. Both unmatched folds to one row displayed as a character carrying the union of facts, marked as returned under both kinds. Exactly one matched yields a bundle plus a candidate, neither marked dual. Both matched yields two bundles.

The projection. Reclassifying a row does not mutate the held proposal. It computes a *projected* proposal (CharacterReviewModel.project) with the facts re-keyed to whatever the new kind resolves onto and re-deduped against that kind's accepted and suppressed sets, and the decision request is built from the projection, never from the held row (Q59). Ticks are carried across by RecordFact.displayRowID, which deliberately excludes the name key (Q78).

Conversion as an operation. RecordEditOperation.convert(basis:to:draft:) rides in the same commitRecordEdits list as create, update, delete and combine, applied in the order the reader performed them, in one save. It is the only operation that touches two tables, which is why commitRecordEdits reads both tables up front whether or not the session opened both.

Trade-offs

The renames reach files that are not otherwise changing, and the archive record types keep generation-scoped names beside the generic ones. The generic ranker costs measurably more than the concrete one it replaced (see the expert level). Conversion mints a fresh UUID, so restoring an archive taken before a conversion recreates the original beside the converted record, and that pair is the reader's to clean up. Conversion also does not carry the old kind's active fact suppressions into the new table (Q73), so an unticked fact can be re-proposed once under the new kind.

Technical Deep Dive

The marker move is the real compatibility boundary, not the schema. appOpenableMarkerVersions is {"12","13"} and extensionOpenableMarkerVersion is "13". No new BootstrapState case was added: markerLagging(generation:) already carries the digit (Q64). The consequence is one-directional. Once the app has published "13", a pre-feature build refuses the store by name, so a downgrade is not a recovery path; the archive is. Between the app converting and the extension next running, a share lands in the pending-capture queue rather than in the store, which is what carries Req 5.6. AsterismSchemaV11 and its recorded-store fixture were deleted in the same commit as the freeze, because a fixture opening a deleted snapshot does not compile (Q65), and the population precondition was verified before the freeze rather than after it.

Ownership on the import update path is the one place last-writer-wins does not reach. modifiedAt guards content; ownership has no timestamp, and the work merge moves workID without touching modifiedAt. So attach gained a third parameter, the resolution map, and the update path passes it: an archived workID is adopted only when it resolves here, or when the local row is itself an orphan with nothing to lose (Q76). Without that, re-importing an archive taken before a work merge would move a place onto a work the payload does not carry and make it invisible everywhere. The insert path keeps the two-argument spelling, whose empty map answers "nothing resolves", so the orphan round trip is unchanged. The character side cannot reach the bug, because its attach leaves an existing relationship alone.

Place collapses are narrower than they look. DuplicateScan.recordSets buckets by UUID only, so every place set has exactly one member and there is no distinct-UUID loser to delete; places converge and never collapse. The write-before-delete and settling-ledger fencing they inherit is the Entry and Work collapse path, where CitationRepointing.repoint runs over both kinds inside one throwing scope before the save, so a failed place fetch rolls the character rewrite back with it. Q84 records the limit: repointing reaches only records whose work resolves, because the phase reads rows(of:) rather than the whole table, so an orphan keeps a dangling citation. Exact parity with the character side, whose nil-work rows are equally unreachable.

The cross-kind sweep has one gate, exposed once. DecisionRequest.suppressedKinds is never empty, and sweeping the coordinator's held rows on it would discard a legitimate same-name row of the other kind after an *accept*, which Req 2.4 forbids. nameKeySuppressedKinds is the gated reading: it returns the empty set unless action == .skip && displayedTargetID == nil. Both consumers, the repository's suppressUnderOtherKinds and the coordinator's discard, take that one accessor, so neither can reach for the natural but wrong field (Q77).

The ranking arm is the generic tax, measured. character-ranking-200x50 moved from 0.00246 s to 0.00368 s, roughly +50%, while every other arm in the same run fell by 3 to 10 percent on a quieter host. That is real, and it is the cost of CharacterRanking becoming one generic order over RecordRow, with facts promoted from a model property to a protocol requirement satisfied by an extension (Q63) so a conformance cannot shadow it and the decode has one canonical implementation. RecordRanking already carries the facts out of the group alongside the ranked order so the work-page open does not decode the cast twice. place-ranking-200x50 measures 0.003547 s, 3.6 percent *under* its sibling, which is the point: the second conformance costs what the first does. Both sit near 35 percent of the 10 ms budget, and no bound was adjusted.

A second install on the dev container is the case Decision 2 was written for. Both configurations mirror, so a Development install is not device-local. A pre-feature build on a second device sees two record types it has no model for and ignores them; it cannot read a place suppression as a character suppression, and cannot clear one. What it *can* do is delete or merge a work, leaving the place rows behind as permanent orphans. That is accepted and bounded by the update window. Task 26's publication run exists because NSPersistentCloudKitContainer publishes record types lazily: a Development run has to push the two new types to the dev container first.

Architecture Impact

Every store behaviour now has one implementation, so a place bug and a character bug are the same bug, and the character suites became the place suites by parameterising over RecordKind.allCases. CLAUDE.md was amended in three places that matter beyond this feature: the "no relationship since V6" rule now names V7's pair as the exceptions, the UUID-column rule is recorded as made four times, and the edit-view rule lists conversion alongside combine and delete as staged-on-Save. The T-2328 violation list did not grow.

Potential Issues

  • dedupe-links-noop measured 0.010032 s, 32 microseconds over a 10 ms budget it
  • is an accepted known issue for. The known issue is not isIntermittent, so a quieter host that lands under 10 ms turns it into a second way to be red.

  • Guardrail refusals were 7 for the combined request against 6 for the
  • character-only one, exactly at the bar as amended (Q62), with no headroom. Refusals are a classifier over instructions plus note text, so a future instruction edit can move it.

  • The combined schema fits fewer tokens: one of 125 corpus sources crossed the
  • context window under the combined arm only. The oversized-source skip handles it, but the overflow rate in real sweeps is unmeasured.

  • Conversion plus sync can resurrect the source. A pre-feature build holding an
  • offline edit to the converted-away character re-creates it after the delete. Decision 1 names this; there is no tombstone.

  • Q73's consequence is visible once: an unticked fact does not carry its
  • suppression across a conversion, so it can be re-proposed under the new kind.

  • mergeImportedRecords opens with a whole-table fetch per kind, now four rather
  • than two. That matches the Work and Entry steps for a bulk path.

Completeness Assessment

Fully implemented

  • 1.1 One request returns both kinds. ExtractionResult.characters and
  • .places in ExtractionResult.swift; bounds, lane class and failure handling untouched in CharacterExtractionBounds.swift.

  • 1.2 Grounding runs one rule set over both arrays:
  • CharacterGrounding.ground(_:kind:cap:), with dropReason shared and the capital-letter rule applied to places unchanged (Q42). CharacterGroundingTests.

  • 1.3 CharacterExtractionBounds.maximumPlaceCandidates = 12, separate from
  • maximumCandidates = 24; an undecodable response is a failed attempt (Q26).

  • 1.4 Coverage stays one record per source revision; CompletedSource and
  • producedNone unchanged, filtered copies counting as decided (Q25).

  • 1.5 CharacterExtractionAssembler.applyDualKindRule, all four arms, per
  • source response only.

  • 1.6 Per-kind dedup through ExtractionCandidate's per-kind records,
  • acceptedFacts and suppressions dictionaries.

  • 1.7 The slash split runs inside groundName(_:in:kind:) for both kinds;
  • reclassification re-runs matching through CharacterReviewModel.project.

  • 1.8 Measured at design against the frozen corpus: R = 11 of 252 (4.4%,
  • bar 10%), no accepted character lost, refusals 7 against 6 at the amended bar (Q62). prototype/prototype-findings.md. The shipped instructions are that run's text.

  • 2.1 proposalsIndicatorSection counts held rows of both kinds; the review
  • sheet labels each section by kind and discloses a union row. testTheSweepRaisesAnIndicatorAndTheListPresentsBothProposals.

  • 2.2 CharacterReviewModel.reclassify, ticks carried by displayRowID,
  • row never removed, canAccept per Q80, chosen kind held in the ledger. testReclassifyingACharacterRowKeepsItAsAPlace.

  • 2.3 Preview at toggle time plus commit re-verification; .reRouted refusal
  • evaluated under the displayed kind. "Re-routing is evaluated under the request's kind".

  • 2.4 commitDecision writes under request.kind only, with
  • suppressUnderOtherKinds the single exception. Four named arms in CharacterExtractionRepositoryTests cover isolation, the dual-kind skip, the reclassified single-kind skip and the accept clearing only its own kind.

  • 2.5 Decisions commit outside the edit transaction; the indicator is absent
  • in edit mode (testTheProposalsIndicatorIsAbsentInEditMode).

  • 3.1 Place in AsterismSchemaV13, full character shape, retained key.
  • 3.2 Edit-mode create, edit and delete through commitRecordEdits;
  • "Add a place" footer; hand-creation clears a standing candidate suppression. testThePlacesCardOpensThePlaceEditorFromItsLineAndItsFooter.

  • 3.3 Combine is same-kind only; a cross-kind combine refuses
  • .kindMismatch ("A combine across kinds refuses .kindMismatch").

  • 3.4 Deletion suppresses the group's deletion key set and its fact triples
  • through the generic deletionKeys(of:).

  • 3.5 Work deletion, merge and entry collapse all have place arms, each with
  • a named test in PlaceDuplicateMachineryTests.

  • 3.6 A dangling citation keeps its text and span and is never an integrity
  • error; covered by the generic presentation and WorkDetailReadTests.

  • 3.7 convert in LibraryRepository+RecordEditing.swift: new UUID,
  • retained key carried, source triples suppressed under the old kind with no name-key suppression, destination clears computed over the basis, current and draft key sets, source group deleted whole, torn refusal. Five parameterised tests plus testConvertingInTheEditorMovesTheLineAndSurvivesSave.

  • 4.2 "A place's facts carry their citations in capture order (4.2)".
  • 4.3 "Places rank over the work's places alone, name order among equals (4.3)".
  • 4.4 "Entry detail names the places citing that entry, in name order".
  • 5.1 BackupV12 at (12, 13), 11/12 refused by name, places and
  • placeSuppressions arrays, orphan place round trip, re-recorded backup-12-13-golden.json, and a new test tying the archive's schema version to the live schema.

  • 5.3 Same-UUID place convergence, distinct-UUID sets never formed, torn
  • place disclosed and refused by the exporter. Four named tests.

  • 5.4 Suppressions converge by actionAt with clear beating an equal time,
  • through the generic resolvedSuppressions/suppressionPrecedes.

  • 5.5 Orphan tolerance on every path: "An orphaned place forms no set and
  • survives a reconcile pass", "A place whose work a pre-feature build deleted is a permanent orphan", "rows(of:) hides an orphan and rows(ids:) reaches it".

  • 5.6 V12RecordedStoreTests: "openForApp converts to 13.0.0, adding two
  • empty tables", plus the extension refusal that routes a share to the pending queue.

  • 6.2 place-ranking-200x50 at 0.003547 s against a 10 ms budget and a 50 ms
  • ceiling (verification-run.md §2).

Partially implemented

  • 4.1 The Places section, its pills and its edit-mode card are all in place,
  • and a work with no places shows no place *section*. The manual pass row was relabelled "Look for characters and places" and stays in the Characters section, so a place-less work does show one place-related string. The design specifies the relabel and task 27 records it as a designed exception, but no decision-log row reconciles it with Req 4.1's wording.

  • 5.2 The code half is done and is what Decision 2 is about: a separate
  • record type a pre-feature build cannot read or clear. The publication of Place and PlaceSuppression to the dev container, and the confirmation that existing record types keep syncing, are task 26 and have not run.

Missing or pending

  • 6.1 The phone comparison is task 26 and is an owner step. The host signal
  • over the prototype corpus was median 125% and p90 123% of the character-only request, exactly on the 25% bar. The gate is unmeasured on device, and the levers if it fails are named in the design: lower the place cap first, then shorten the place paragraph.

  • Task 27 is otherwise complete (CLAUDE.md, schema-migration.md,
  • testing.md, the design and style docs, specs/OVERVIEW.md and CHANGELOG.md all moved), but its verification-run.md line for Req 6.1 cannot be written until task 26 runs.

Divergences from the design

Recorded, with the row that records each:

  • facts moved onto RecordRow as a requirement, and two more files were
  • renamed with their types (Q63).

  • No new BootstrapState case; markerLagging(generation:) carries "12" (Q64).
  • The schema-reference rename landed in the freeze task, not the one after (Q65).
  • Place/PlaceSuppression borrowed the character archive records until the
  • codec rename (Q66).

  • Place.make(work: nil) leaves a freshly minted workID (Q67).
  • Req 1.5's survival predicate keeps character-extraction's existing
  • no-facts-left exception, which is narrower than Req 1.5 and Q36 as written (Q68).

  • The ledger's merged keeps the reader's kind and the older target; the preview
  • re-runs in the review model rather than in the ledger (Q69).

  • The dual-kind fold carries facts only, not the folded copy's aliases (Q70).
  • CharacterExtractionSource kept its name against the rename table, to avoid
  • colliding with AsterismIntelligence's existing ExtractionSource (Q71).

  • CharacterExtractionContext's character-only initialiser moved to a test-side
  • extension rather than being deleted (Q72).

  • .convert ignores draft.kind; to: is the authority, and conversion does
  • not carry fact suppressions across kinds (Q73).

  • The M4 reconcile fixture seeds no places, and the design's reconcile-arm bullet
  • was dropped (Q74).

  • attach gained a resolution-map parameter and the import update path narrows
  • ownership adoption (Q76).

  • The cross-kind sweep reads one gated nameKeySuppressedKinds accessor rather
  • than returnedKinds at each consumer (Q77).

  • Ticks carry by displayRowID, not by the proposal-local index Q59 named (Q78).
  • The kind control is ConstellationSegmentedControl, not a .segmented
  • Picker (Q79).

  • canAccept stays true for a reclassified candidate the model reported with no
  • facts, or with an unstruck proposed alias (Q80).

  • Staging a conversion dismisses the editor sheet; the take-back is reached by
  • reopening the line from the other card (Q81).

  • The AppLibraryModel conflict-routing bullet was dropped as naming a seam that
  • does not exist (Q82).

  • The wide-layout place case runs at the default text size (Q83).
  • Collapse repointing reaches only records whose work resolves (Q84).

Divergences with no row:

  • The review sheet's kind-control identifier is
  • character-review-kind-<rowid>-<kind>, one per segment, where the design names character-review-kind-<rowid>. It follows from Q79's shared control needing a per-segment identifier, but Q79 does not say so and the design's spelling is what a UI test would be written against.

  • The Req 4.1 tension described under "Partially implemented": the relabelled
  • manual-pass row is a place-related element on a place-less work. The design specifies it and task 27 documents it, but nothing in the decision log records the requirement being read that way.

Important changes — detailed

Schema V13 adds Place and PlaceSuppression and moves the readiness marker to "13"

Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift

Why it matters. Data safety: a bump is a one-way door. Once the app publishes "13", a pre-feature build refuses the store by name, so a downgrade is not a recovery path and the archive is the only way back. The V11 snapshot, its recorded-store fixture and the marker-twelve suite were deleted in the same commit as the freeze.

What to look at. AsterismSchemaV13 (entities 15 to 17), AsterismV13MigrationPlan [V12, V13] one lightweight stage; appOpenableMarkerVersions {"12","13"}, extensionOpenableMarkerVersion "13" in LibraryRepository+Bootstrap.swift

Takeaway. A schema bump moves four things together (live schema, frozen snapshot, marker generation, archive generation), and retiring the previous snapshot needs positive proof the whole device population has passed it, verified before the freeze rather than after.
Rationale. Substitution rather than addition keeps a V11 snapshot alive for nothing; the owner confirmed every device on "12" on 2026-09-10. No new BootstrapState case because markerLagging(generation:) already carries the digit (Q48, Q64, Q65).

Store code becomes generic over a RecordRow seam with conformance-owned fetches

Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift

Why it matters. API surface and correctness: one implementation of groups, repointing, ranking, decision commit, edit commit and archive merge for both kinds, so a place bug and a character bug are the same bug. The load-bearing constraint is that generic code writes no #Predicate.

What to look at. RecordRow and SuppressionRow protocols; CharacterRecord walks its inverse, Place runs a predicate over its work ids; recordID rather than id; facts is a protocol requirement satisfied by one extension

Takeaway. Generic SwiftData is workable if every fetch lives in the conformance. Name the identity recordID, not id, because PersistentModel already vends id: PersistentIdentifier and a generic row.id is ambiguous.
Rationale. A copy would be about 2,000 lines kept in step by hand; a switch on kind spreads into every repository function. The shape was spiked in a standalone package before the design was accepted (Decision 3, Q52, Q58, Q63).

Import update path narrows ownership adoption with a work-resolution map

Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift

Why it matters. Data safety: ownership carries no timestamp, so the modifiedAt and actionAt guards cannot see it. Without the narrowing, re-importing an archive taken before a work merge moves a place onto a work the payload lacks and it becomes invisible everywhere; the same shape displaces a suppression off its work, which silently un-suppresses a name the reader refused.

What to look at. attach(to:archivedWorkID:workTargets:) in RecordRow.swift (Place and PlaceSuppression conformances); mergeImportedRecords and mergeImportedSuppressions pass the map on the update path only

Takeaway. A value guard protects only what the guard's timestamp actually moves with. A column that changes without touching modifiedAt needs its own rule, not the content guard.
Rationale. The character side cannot reach the bug because its attach leaves an existing relationship alone; the place rule states the same intent over a UUID column. The insert path keeps the two-argument spelling so the orphan round trip is unchanged (Q76).

Per-kind suppression is a separate record type, not a column

Packages/AsterismCore/Sources/AsterismCore/Models.swift

Why it matters. Data safety under sync: both configurations mirror to CloudKit and a pre-feature build shares the container during an update window. A record-kind column would be invisible to that build, so accepting a character "Bay" there would clear a place suppression it cannot see.

What to look at. PlaceSuppression as its own @Model; SuppressionRow behind it so the convergence logic is shared

Takeaway. When old builds share a synced container, a new discriminator column is a hazard and a new record type is inert. Overloading an existing tolerated-enum raw value is worse: unknown raws read as the default.
Rationale. Namespaced name keys were rejected because they hide a discriminator inside a value that is also matched, displayed and exported (Decision 2).

The cross-kind name-key sweep is gated once, on DecisionRequest

Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift

Why it matters. Correctness and user-visible data loss: suppressedKinds is never empty, so sweeping held rows on it after an accept would discard a legitimate same-name row of the other kind from another source, which Req 2.4 forbids.

What to look at. DecisionRequest.nameKeySuppressedKinds (guard action == .skip, displayedTargetID == nil); consumed by suppressUnderOtherKinds in the repository and CharacterExtractionCoordinator.discard

Takeaway. When two consumers must apply the same gate, expose the gated reading as the only accessor rather than repeating the predicate. Two fields that happen to be equivalent over a two-case enum protect nothing by themselves.
Rationale. Over two kinds returnedKinds.count > 1 and suppressedKinds.count > 1 are the same predicate; the protection is the skip-with-no-target gate, and one accessor means later tasks cannot pass the natural but wrong field (Q56, Q77).

Conversion between kinds is delete-and-recreate, staged on Save

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

Why it matters. User-visible and data safety: it rescues facts, citations and the retained name key from a record filed under the wrong kind, in one save, without extending the T-2328 tap-time-write list.

What to look at. RecordEditOperation.convert(basis:to:draft:); LibraryRepository.convert over source and destination tables (the only operation touching two); WorkDetailModel.convertRecord(id:) stages it and flips the draft's kind

Takeaway. A UUID has convergence meaning only inside one entity. Moving a record between tables under the same id manufactures a false identity that no reconciler pairs; minting a new one makes it an ordinary delete plus an ordinary create, both already handled by sync and archive.
Rationale. Staging on Save follows the edit-view rule; the draft rides on the convert so no later operation names a UUID the session has not minted. Fact triples suppress under the old kind but no name-key suppression, following the combine precedent (Decision 1, Q35, Q54, Q73).

Reclassification builds a projected proposal and carries ticks by display row id

Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift

Why it matters. Correctness: the decision request was otherwise built from the held proposal, so a place decision would have carried the character-kind fact set; and fact identity includes the name key, so re-keying silently dropped the reader's unticks.

What to look at. CharacterReviewModel.project(_:to:inputs:), CharacterReviewRow.projected, reclassify(_:to:) carrying ticks by RecordFact.displayRowID

Takeaway. When a user's per-item state must survive a re-keying, key it on something the re-keying does not touch. A positional index is only stable when the transform drops nothing.
Rationale. displayRowID (source token, quote, statement) already excludes the name key for this reason and is what the work page and the sheet both key reader state on. Q78 amends Q59's proposal-local index.

Key decisions

Generic store, kind-aware pipeline, kind-parameterised views.

Repository code operating on a record's shape becomes generic over RecordRow with a sibling SuppressionRow; the extraction pipeline, ledger, coordinator and review model stay single implementations carrying a RecordKind. A parallel copy, a kind switch and a kind column on Character were all rejected. (Decision 3)

Conversion mints a new identity.

A converted record is a new row of the other kind with a fresh UUID carrying the retained name key, and the original is deleted. Reusing the UUID would give sync a pair of same-UUID rows in two entities that nothing reconciles. (Decision 1)

Place suppressions are their own record type.

A record-kind column on CharacterSuppression would let a pre-feature build sharing the CloudKit container clear a place suppression it cannot see. A separate type is ignored entirely by a build that does not know it. (Decision 2)

A dual-kind name with no existing match displays as a character.

"The kind the model listed first" is undefined across two arrays, and letting model output decide reader-visible behaviour is forbidden. A fixed default removes the model from the choice and points at the reader's one-tap correction. (Q20)

Reclassification does not re-check the name key against the new kind's suppressions.

Reclassify-then-accept is the deliberate override of a stale skip, and a row must never vanish from an open list on a toggle. (Q31)

Places get their own candidate cap but share every other cap.

A shared candidate cap would let a place-heavy note crowd out characters; the other caps bound single values and do not compete. (Q15)

The combined response that cannot be decoded in full fails the source.

Accepting one kind's array and marking the source covered would lose the other kind silently, the direct analogue of the existing never-truncate rule. (Q26)

A committed record step calls onMutation() before load().

Reconcile was reached only through the diagnosis refresh, which the record step did not trigger, so a held bundle targeting a deleted or converted record stood until the next arrival. This also fixes the pre-existing delete gap. (Q55)

The M4 reconcile fixture is left alone; the place ranking arm is Risk 1's whole verification.

The duplicate fixture seeds zero character rows, so places at the character density is zero; seeding places alone would move a 21-minute target's band to measure a cost the character side never paid there. (Q74)

Req 1.8's regression bar allows one extra guardrail refusal.

The final-text run measured seven combined refusals against six character-only. Refusals are a classifier over instructions plus note, and one sentence moved two sources one way and one the other; the owner accepted it as one-source variance. (Q62)

Collapse repointing reaches only records whose work resolves.

rows(of:) keeps the phase off a whole-table read the reconciler's budget cannot afford, and an orphan already degrades in display rather than failing. Exact parity with the character side. (Q84)

The archive's schema version is pinned to the live schema by a test.

A test asserts the archive's schema version is the live schema's and the format one below, so a future bump cannot forget the fourth thing it has to move. (Q66 and the phase 2 review)

Red commits may name types their green half introduces.

The red/green split is one task and the branch squash-merges, so "compiles at every commit" is read as every green commit; each red commit says so in its message. (Q75)

The review kind control's identifiers are per segment.

The shared segmented control emits one identifier per segment beneath the container's, so a UI journey taps a segment rather than the container. Recorded at the pre-push review. (Q85)

The relabelled manual-pass row is a designed exception to Req 4.1.

"Look for characters and places" stays on a place-less work because one manual pass covers both kinds; the requirement's "no place-related element" is read as no place data. Recorded at the pre-push review. (Q86)

Review findings

SeverityAreaFindingResolution
majorDuplicateReconciler.stage, entry plansTwo predicated Place and PlaceSuppression fetches ran per deletion plan (about 500 store round trips per settling pass), against the file's own read-per-chunk convention; the performance fixture could not see it because it seeds no places.PlaceRepointRows reads both tables once per chunk, bucketed by work id; plans use the value-based CitationRepointing overload. Replay reads per plan by design because it commits between plans.
minorCharacterReviewModel.ReviewInputsMatch targets and accepted-fact sets were rebuilt on every lookup, once per row per refresh, on the main actor.Derived per kind once at assignment through an explicit initialiser and a setRecords mutator.
minorWorkDetailModel.combineTargets / CharacterEditorViewComputed twice per editor body with a linear scan of staged operations per candidate.Computed once into a let in the body; isConverted backed by a set of converted ids.
minorLibraryRepository+RecordEditing commit readBoth record tables and both suppression tables were fetched even when no conversion was staged.The kinds to read are derived from the operations; both tables only when a convert is present.
minorCharacterExtractionCoordinator.contextsContexts for works the sweep processed but never tracked were never pruned; the bound was incidental.pruneContexts() to the ledger's tracked works after process and reconcile.
minorArchiveRecordBuilders record and suppression buildersFour builders re-transcribed every column inline, against the file's own make-then-apply idiom.One generic body per table kind through RecordRow.make plus LibraryRepository.apply; the four public entry points stay.
minorBackupV12Exporter record mappersCharacter mappers reached past the abstraction for the owner; the place pair duplicated the field lists.Both read ownerWorkID. A single generic mapper was not possible without changing the archive types, because the place records carry a non-optional work id; stated in the doc comment.
minorLibraryRepository+WorkMerge moveCharacters / movePlacesThe generic-notes re-canonicalisation loop was duplicated verbatim; moveCharacters carried an unused context parameter.One generic helper over RecordRow; the unused parameter dropped.
minorDuplicateScan.scanThe character and place candidate-then-enumerate blocks were identical apart from the model type.One generic splitRecordRows helper called twice.
minorCharacterReviewModel per-kind copyThe re-route and torn disclosures and the cross-kind hint branched on kind inline instead of using the presentation table.Pronoun and the three strings moved into RecordKindPresentation; rendered text unchanged.
minorCharacterReviewRow.canAcceptA reclassified row whose facts all deduped away but which kept an unstruck proposed alias had Keep disabled, though Q36 counts a new alias as content.Widened to surviving aliases; Q80 amended; a test added.
minordocs/agent-notes/rule-wire-format.mdThe live archive substrate was still named as BackupV11 at 11/12.Moved to BackupV12 at 12/13 with the rename history extended.
minorReq 3.5 orphan repointingCollapse repointing never reaches an orphan place and no decision recorded the narrowing.Q84 records it as parity with the character side and Req 5.5's tolerated state.
minorRecordEditRefusal.recordGoneThe refusal path was asserted nowhere, though an open editor can outlive its record through a sync arrival or a collapse.A convert arm over both kinds added, expecting the refusal with nothing written.
nitRecordKind.otherThe kind flip was spelled inline at several sites and once in the presentation table.One accessor on RecordKind; production sites use it.
nitPlaceSuppression defaultsLiteral strings where the character twin uses the enums' raw values.Raw values, with the doc comment corrected.
nitextractionCandidatesBranched on the scoping argument four times for one question.Four groupings, each with one conditional source.
nitWorkDetailView add recordA kind ternary chose between two wrappers of a private method that already took the kind.addRecord made public and called directly.
nitMaintenanceViewModels nounsA second spelling of the record-kind nouns outside the presentation table.A plural accessor on the table; the two record cases map through it.
nitQ67 rationaleClaimed no production call site passes a nil work; four do, each behind a non-empty work guard.Rationale amended to describe the make-insert-attach ordering.
nitEntry detail orphan placeThe one read path without an orphan arm.A fourth orphan seed asserted absent from the entry's citing places.
minorDuplicateResolutionContract.character / .placeTwo contract cases with identical payloads, propagating into twin accessors and loops in the resolution model and view.Deferred: unifying the case changes the app model and view's public shape and their tests; a candidate for a follow-up smolspec.
minorRecordGroup facts decodingfactsData is decoded and re-encoded in the group build and decoded again by the ranker, three Codable passes per record per read, on a path no arm measures.Deferred: carrying decoded facts on the group is a design decision, not a patch; recorded under double-check.
minorCharacterReviewModel initialiserFive coordinator-facing closures where a host protocol would do.Deferred: collapsing them changes the test seam; the closures are each one line at the sole production site.
minorM5RepositoryTestSupport seedsFour seed structs where two suffice, and three duplicated reader pairs.Deferred: test support only; the review does not modify test files for refactoring.
nitWorkDetailModel.characterDrafts / placeDraftsPublic projections read only by tests.Deferred: removal would change the tests that read them.
nitCharacterEditorView identifiersThe editor's identifiers still say character for a place sheet.Deferred: the UI journeys are written against them; one sheet exists at a time so nothing is ambiguous.
nitExistingCharacterOne pipeline type kept a character name though it now carries places.Deferred: a name-only rename with test churn; Q71 covers the pipeline prefix.
nitReq 4.1 negative half, memory-warning cancelNo test asserts a place-less work shows no place element; no arm cancels an in-flight attempt on memory warning.Deferred: both are coverage additions for the UI and coordinator suites, worth a follow-up.

Tests

Source: local run at 2026-09-11T10:24:06+10:00 · snapshot 1c7fc8dfcf45a275c9753542622171dd4354040d

Baseline: none

Execution: passed · JUnit: 1 file · Coverage: 1 file · Baseline: absent

Coverage scope: every test in the repository

Totals: 2700 passed · 0 failed · 44 skipped · 0 errored · 0 flaky

New and removed tests

Derived by declaration name, from the diff (no baseline run).

Diff coverage

FileAdded linesCoveredDiff coverage
Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift103no coverage data
Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift2no coverage data
Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift353no coverage data
Asterism/Asterism/UITestLaunchSupport.swift75no coverage data
Asterism/Asterism/ViewModels/AppLibraryModel.swift19no coverage data
Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift10no coverage data
Asterism/Asterism/ViewModels/EntryDetailModel.swift10no coverage data
Asterism/Asterism/ViewModels/MaintenanceViewModels.swift11no coverage data
Asterism/Asterism/ViewModels/SettingsBackupModel.swift13no coverage data
Asterism/Asterism/ViewModels/WorkDetailModel.swift275no coverage data
Asterism/Asterism/Views/CharacterEditorView.swift106no coverage data
Asterism/Asterism/Views/CharacterReviewView.swift76no coverage data
Asterism/Asterism/Views/DuplicateResolutionView.swift8no coverage data
Asterism/Asterism/Views/EntryDetailView.swift26no coverage data
Asterism/Asterism/Views/RecentView.swift7no coverage data
Asterism/Asterism/Views/RecordKindPresentation.swift130no coverage data
Asterism/Asterism/Views/WorkDetailView.swift227no coverage data
Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift273no coverage data
Asterism/AsterismTests/CharacterReviewModelTests.swift617no coverage data
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift35no coverage data
Asterism/AsterismTests/IntegrationSafetyNetTests.swift17no coverage data
Asterism/AsterismTests/SettingsBackupModelTests.swift20no coverage data
Asterism/AsterismTests/SettingsImportTests.swift6no coverage data
Asterism/AsterismTests/WorkDetailCharacterTests.swift667no coverage data
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift135no coverage data
Asterism/AsterismUITests/CharacterExtractionUITests.swift361no coverage data
Asterism/AsterismUITests/WideLayoutUITests.swift61no coverage data
CHANGELOG.md113no coverage data
CLAUDE.md22no coverage data
Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift6534100%
Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift3no coverage data
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift301114100%
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift6816100%
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift684790%
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift25no coverage data
Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift1no coverage data
Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift2215100%
Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swift6no coverage data
Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift253116100%
Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift4no coverage data
Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift487100%
Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift2no coverage data
Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swift543497%
Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swift986291%
Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swift2062872%
Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift1no coverage data
Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift14624100%
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift1268095%
Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift196100%
Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift683297%
Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift11667%
Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift1no coverage data
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift21no coverage data
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift1616100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift66100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift5914100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift262100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift2615100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift726198%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift4531100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift11100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swift78747697%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordExtraction.swift33921398%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift3no coverage data
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift99100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift3116100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift5024100%
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift54100%
Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift1no coverage data
Packages/AsterismCore/Sources/AsterismCore/Models.swift1894087%
Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift113100%
Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift121100%
Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swift2010100%
Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swift1063792%
Packages/AsterismCore/Sources/AsterismCore/RecordKind.swift2400%
Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift309100%
Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift36910098%
Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift42100%
Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift11100%
Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swift3814100%
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift22414499%
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift7no coverage data
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift47939%
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift612172%
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift1133294%
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift614198%
Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift4310100%
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift432100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift4747100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift158124100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift3737100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift1816100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift221990%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swift12001020100%
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swift2059890%
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift44100%
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift139100%
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift121100%
Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift1710100%
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift1914100%
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift65851399%
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift51237998%
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift3333100%
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift2828100%
Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift1515100%
Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift1no coverage data
Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift44100%
Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift400%
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.json1no coverage data
Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift7251100%
Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift512551%
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift11100%
Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift200%
Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift200%
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift6400%
Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift294178100%
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift3514100%
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swift6936100%
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift500%
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift33100%
Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift1087097%
Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift44100%
Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift77100%
Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swift50537698%
Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swift36123799%
Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swift170115100%
Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift21100%
Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift11100%
Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift73100%
Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift33100%
Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift1515100%
Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swift1416198%
Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swift12768100%
Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift13no coverage data
Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift205179100%
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift22100%
Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift87100%
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift55100%
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift98100%
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift22100%
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift291222100%
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swift17497100%
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift141119100%
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift19199100%
Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift4330100%
docs/agent-notes/rule-wire-format.md6no coverage data
docs/agent-notes/schema-migration.md185no coverage data
docs/agent-notes/testing.md94no coverage data
docs/asterism-design.md32no coverage data
docs/asterism-style-guide.md12no coverage data
specs/OVERVIEW.md4no coverage data
specs/place-extraction/decision_log.md24no coverage data
specs/place-extraction/design.md5no coverage data
specs/place-extraction/implementation.md447no coverage data
specs/place-extraction/tasks.md39no coverage data
specs/place-extraction/verification-run.md212no coverage data
specs/retire-migration-chain/library-graph-baseline.txt11no coverage data

Aggregate diff coverage: 97% (6431 of 6624 measurable added lines).

Overall coverage

Head 93.5% (93008 of 99437 lines)

132 of 175 changed files matched coverage data.

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base ed3a6d7.

Dependents none found Changed Dependencies none found . Asterism/Asterism …/Asterism/CharacterExtraction Asterism/Asterism/ViewModels Asterism/Asterism/Views Asterism/AsterismTests Asterism/AsterismTests/Helpers Asterism/AsterismUITests …rismCore/Sources/AsterismCore …/Sources/AsterismIntelligence …mCore/Tests/AsterismCoreTests …ts/AsterismCoreTests/Fixtures …sts/AsterismIntelligenceTests docs docs/agent-notes specs specs/place-extraction specs/retire-migration-chain CHANGELOG.mdCHANGELOG.md CLAUDE.mdCLAUDE.md Asterism/Asterism/UITestLaunchSupport.swift…ism/UITestLaunchSupport.swift Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift…erExtractionCoordinator.swift Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift…tion/CharacterExtractor.swift Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift…on/CharacterReviewModel.swift Asterism/Asterism/ViewModels/AppLibraryModel.swift…wModels/AppLibraryModel.swift Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift…uplicateResolutionModel.swift Asterism/Asterism/ViewModels/EntryDetailModel.swift…Models/EntryDetailModel.swift Asterism/Asterism/ViewModels/MaintenanceViewModels.swift…s/MaintenanceViewModels.swift Asterism/Asterism/ViewModels/SettingsBackupModel.swift…els/SettingsBackupModel.swift Asterism/Asterism/ViewModels/WorkDetailModel.swift…wModels/WorkDetailModel.swift Asterism/Asterism/Views/CharacterEditorView.swift…ews/CharacterEditorView.swift Asterism/Asterism/Views/CharacterReviewView.swift…ews/CharacterReviewView.swift Asterism/Asterism/Views/DuplicateResolutionView.swift…DuplicateResolutionView.swift Asterism/Asterism/Views/EntryDetailView.swift…m/Views/EntryDetailView.swift Asterism/Asterism/Views/RecentView.swift…terism/Views/RecentView.swift Asterism/Asterism/Views/RecordKindPresentation.swift…/RecordKindPresentation.swift Asterism/Asterism/Views/WorkDetailView.swift…sm/Views/WorkDetailView.swift Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift…ractionCoordinatorTests.swift Asterism/AsterismTests/CharacterReviewModelTests.swift…aracterReviewModelTests.swift Asterism/AsterismTests/IntegrationSafetyNetTests.swift…tegrationSafetyNetTests.swift Asterism/AsterismTests/SettingsBackupModelTests.swift…ettingsBackupModelTests.swift Asterism/AsterismTests/SettingsImportTests.swift…sts/SettingsImportTests.swift Asterism/AsterismTests/WorkDetailCharacterTests.swift…orkDetailCharacterTests.swift Asterism/AsterismTests/Helpers/MockLibraryProvider.swift…ers/MockLibraryProvider.swift Asterism/AsterismUITests/AccessibilityJourneyUITests.swift…ssibilityJourneyUITests.swift Asterism/AsterismUITests/CharacterExtractionUITests.swift…racterExtractionUITests.swift Asterism/AsterismUITests/WideLayoutUITests.swift…Tests/WideLayoutUITests.swift Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift…e/ArchiveRecordBuilders.swift Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift…re/AsterismCapabilities.swift Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swift…mCore/AsterismSchemaV11.swift Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift…mCore/AsterismSchemaV12.swift Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift…mCore/AsterismSchemaV13.swift Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift…BackupArchiveProjection.swift Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift…pArchiveReferenceChecks.swift Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift…rismCore/BackupExporter.swift Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift…e/BackupGroupProjection.swift Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift…/BackupImportCharacters.swift Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swift…re/BackupImportCreators.swift Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift…ore/BackupImportRecords.swift Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift…e/BackupImportWorkTypes.swift Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift…rismCore/BackupImporter.swift Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift…/BackupJSONCodecSupport.swift Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swift…rismCore/BackupV12Codec.swift Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swift…mCore/BackupV12Exporter.swift Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swift…rismCore/BackupV12Types.swift Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift…rismCore/CanonicalBytes.swift Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift…haracterExtractionTypes.swift Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift…ore/DuplicateReconciler.swift Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift…ore/DuplicateResolution.swift Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift…erismCore/DuplicateScan.swift Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift…mCore/DuplicateWorkload.swift Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift…rismCore/EntryCitations.swift Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift…smCore/LibraryProviding.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift…Repository+BackupImport.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift…itory+BackupImportGates.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift…aryRepository+Bootstrap.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift…pository+BootstrapState.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift…sitory+CharacterEditing.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift…epository+ConfirmImport.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift…ory+DuplicateResolution.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift…yRepository+EntryDetail.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift…ibraryRepository+Export.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swift…epository+RecordEditing.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordExtraction.swift…sitory+RecordExtraction.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift…raryRepository+Redirect.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift…Repository+WorkDeletion.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift…ryRepository+WorkDetail.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift…aryRepository+WorkMerge.swift Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift…mCore/LibraryRepository.swift Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift…re/MembershipReconciler.swift Packages/AsterismCore/Sources/AsterismCore/Models.swift…ces/AsterismCore/Models.swift Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift…Core/ProjectionContract.swift Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift…Core/RecentPresentation.swift Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swift…sterismCore/RecordFacts.swift Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swift…terismCore/RecordGroups.swift Packages/AsterismCore/Sources/AsterismCore/RecordKind.swift…AsterismCore/RecordKind.swift Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift…erismCore/RecordRanking.swift Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift…/AsterismCore/RecordRow.swift Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift…smCore/ShareWorkContext.swift Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift…smCore/WorkMergePlanner.swift Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swift…/WorkRecordPresentation.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift…cterExtractionAssembler.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift…aracterExtractionBounds.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift…aracterExtractionBridge.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift…aracterExtractionLedger.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift…haracterExtractionTypes.swift Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift…ence/CharacterGrounding.swift Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift…igence/ExtractionResult.swift Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift…erExtractionModelClient.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift…ortDegradedRefusalTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift…BackupGoldenExportTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift…kupGroupProjectionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift…ckupGroupRoundTripTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift…pImportTransactionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swift…s/BackupV12ArchiveTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swift…Tests/BackupV12Fixtures.swift Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift…ts/BootstrapActionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift…ootstrapClassifierTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift…strapStateCoverageTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift…/CertificationPathTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift…DuplicateMachineryTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift…s/CharacterEditingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift…tractionRepositoryTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift…sts/CharacterFactsTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift…s/CharacterRankingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift…itationBlobRefreshTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift…uleGroupValidationTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift…CreatorConvergenceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift…CreatorRoleSeedingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift…s/CreditReconcilerTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift…sSiteDuplicateScanTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateReconcilerTestSupport.swift…teReconcilerTestSupport.swift Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateScanTests.swift…ests/DuplicateScanTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift…numTolerancePolicyTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift…eTests/FanOutWriteTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift…reArchiveGeneratorTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift…/FrozenLibraryPathTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift…reTests/GroupFetchTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift…ests/GroupOrderingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift…IdentityResolutionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift…braryGraphBaselineTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift…braryToleranceScanTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift…ValidatorToleranceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/LookupFirstCaptureStateTests.swift…pFirstCaptureStateTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift…lkChunkPerformanceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift…teScalePerformanceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift…M4ScalePerformanceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift…M5RepositoryTestSupport.swift Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift…sts/MarkerContractTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swift…GenerationThirteenTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift…mbershipReconcilerTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift…s/MembershipTestSupport.swift Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift…mbershipValidationTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift…BootstrapLifecycleTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift…ests/ModelContractTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift…/MultiSiteReadPathTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift…MultiSiteReviewFixTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swift…DuplicateMachineryTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swift…eTests/RecordGroupTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swift…oreTests/RecordRowTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift…reshUnionInvariantTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift…ests/RuleSelectionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift…s/ShareWorkContextTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift…sts/SiteReconcilerTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift…iteUnionProjectionTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift…ests/StoreMetadataTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift…RLOptionalSequenceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swift…V12RecordedStoreFixture.swift Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swift…s/V12RecordedStoreTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift…ts/V4RecordedStoreTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift…sts/WorkDetailReadTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeConvergenceTests.swift…orkTypeConvergenceTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypeOrderingTests.swift…s/WorkTypeOrderingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WorkTypePlumbingTests.swift…s/WorkTypePlumbingTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift…teSiteRelationshipTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift…rkURLCompatibilityTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift…gHostWorkURLImportTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift…stWorkURLValidatorTests.swift Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.json…ures/backup-12-13-golden.json Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift…xtractionAssemblerTests.swift Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swift…erExtractionBridgeTests.swift Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift…erExtractionLedgerTests.swift Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift…CharacterGroundingTests.swift Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift…ractionModelClientTests.swift docs/asterism-design.mddocs/asterism-design.md docs/asterism-style-guide.mddocs/asterism-style-guide.md docs/agent-notes/rule-wire-format.md…ent-notes/rule-wire-format.md docs/agent-notes/schema-migration.md…ent-notes/schema-migration.md docs/agent-notes/testing.mddocs/agent-notes/testing.md specs/OVERVIEW.mdspecs/OVERVIEW.md specs/place-extraction/decision_log.md…ce-extraction/decision_log.md specs/place-extraction/design.md…cs/place-extraction/design.md specs/place-extraction/implementation.md…-extraction/implementation.md specs/place-extraction/tasks.md…ecs/place-extraction/tasks.md specs/place-extraction/verification-run.md…xtraction/verification-run.md specs/retire-migration-chain/library-graph-baseline.txt…in/library-graph-baseline.txt
addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift Modified +103 / -22
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift b/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swiftindex b08295b..4d21d1d 100644--- a/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift+++ b/Asterism/Asterism/CharacterExtraction/CharacterExtractionCoordinator.swift@@ -44,6 +44,16 @@ final class CharacterExtractionCoordinator {     private let lane: ModelLane      private var ledger = CharacterExtractionLedger()+    /// Each work's last candidate read, kept so the review sheet can preview a+    /// reclassification without paying for a second fetch on a toggle (Req 2.2,+    /// design §Review).+    ///+    /// The preview needs the *destination* kind's accepted facts and suppressed+    /// identities, and this is the only read that has them. It is a snapshot,+    /// deliberately: a suppression that synced in since the read affects the+    /// preview and nothing else, because the commit dedups against the store.+    /// Refreshed by every pass over the work and by `reconcile()`.+    private var contexts: [UUID: CharacterExtractionContext] = [:]     /// 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>?@@ -74,6 +84,10 @@ final class CharacterExtractionCoordinator {      var worksWithProposals: Set<UUID> { ledger.worksWithProposals } +    /// The work's last candidate read, or nil when no pass or reconcile has+    /// seen the work in this run. The reclassify preview's input (Req 2.2).+    func context(for work: UUID) -> CharacterExtractionContext? { contexts[work] }+     /// Req 1.11: hidden, not disabled — an action that can never do anything is     /// not an action.     var canRunManualPass: Bool { isModelAvailable }@@ -88,17 +102,50 @@ final class CharacterExtractionCoordinator {      // 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)+    /// Drops the row a committed decision decided. One row per proposal key per+    /// work (Q83/Q100), so the key identifies it — the **assembled** kind and+    /// the name key, whichever kind the row was displaying at the time (Q28).+    ///+    /// `nameKeySuppressedKinds` is what the commit **wrote** a name-key+    /// suppression under, and the only field that may be passed here is+    /// `DecisionRequest.nameKeySuppressedKinds` — the gated one, empty for+    /// everything but a candidate skip (Q77). `suppressedKinds` is the ungated+    /// reading and is never empty: sweeping on an *accepted* union row's copy of+    /// it would discard a legitimate same-name row of the other kind that+    /// another source produced, which Req 2.4 forbids.+    ///+    /// Two of them is Q56's case: the reader skipped the name under both kinds,+    /// so a row of that name key held under the other kind — a "Bay — Place"+    /// another source produced — would outlive a suppression that contradicts+    /// it, and it goes too.+    ///+    /// Passing nothing discards the one row, which is what every decision but+    /// that skip does.+    func discard(+        _ key: ProposalKey, for work: UUID, nameKeySuppressedKinds: Set<RecordKind> = []+    ) {+        ledger.discard(key, for: work)+        guard nameKeySuppressedKinds.count > 1 else { return }+        for kind in nameKeySuppressedKinds where kind != key.kind {+            ledger.discard(ProposalKey(kind: kind, nameKey: key.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+    /// about, so the refreshed sheet re-presents it against the record 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)+    func retarget(_ key: ProposalKey, for work: UUID, to recordID: UUID?) {+        ledger.retarget(key, for: work, to: recordID)+    }++    /// Req 2.2: the reader changed a row's kind, and the sheet has previewed+    /// what that kind resolves onto (Q24). Both go onto the held row, so the+    /// choice survives the sheet being closed and reopened within the app run,+    /// and a later settlement's merge keeps it (Q28, Q69).+    func reclassify(+        _ key: ProposalKey, for work: UUID, to kind: RecordKind, target: UUID?+    ) {+        ledger.reclassify(key, for: work, to: kind, target: target)     }      // MARK: - The activation sweep (Req 1.1, 1.2)@@ -140,9 +187,9 @@ final class CharacterExtractionCoordinator {             return         } -        let candidates: [CharacterExtractionCandidate]+        let candidates: [ExtractionCandidate]         do {-            candidates = try await library.characterExtractionCandidates(+            candidates = try await library.extractionCandidates(                 limit: CharacterExtractionBounds.worksExamined, workIDs: nil)         } catch {             // A library that is not ready yet skips this activation in silence@@ -187,9 +234,9 @@ final class CharacterExtractionCoordinator {     func reconcile() async {         let tracked = ledger.trackedWorks         guard !tracked.isEmpty else { return }-        let rows: [CharacterExtractionCandidate]+        let rows: [ExtractionCandidate]         do {-            rows = try await library.characterExtractionCandidates(+            rows = try await library.extractionCandidates(                 limit: tracked.count, workIDs: tracked)         } catch {             CharacterExtractionLog.failure(@@ -200,10 +247,20 @@ final class CharacterExtractionCoordinator {         for row in rows {             states[row.workID] = WorkExtractionState(                 revisions: row.revisionsBySource,-                characterIDs: Set(row.characters.map(\.id)))+                recordIDs: Dictionary(+                    uniqueKeysWithValues: RecordKind.allCases.map {+                        ($0, Set(row.records(of: $0).map(\.id)))+                    }))+            // The newest read of a tracked work is the one a reclassify preview+            // should be run against (Req 2.2).+            contexts[row.workID] = CharacterExtractionContext(row)         }+        // A work the read no longer offers is gone or torn; its rows go below,+        // and its retained read goes with them.+        for work in tracked where states[work] == nil { contexts[work] = nil }          let invalidated = ledger.reconcile(against: states)+        pruneContexts()         if !invalidated.isEmpty {             CharacterExtractionLog.note("reconcile invalidated \(invalidated.count) work(s)")             // A voided attempt's answer is discarded whatever it says; cancelling@@ -212,6 +269,19 @@ final class CharacterExtractionCoordinator {         }     } +    /// The retained reads, bounded by the ledger rather than by nothing.+    ///+    /// `contexts` used to be cleared only for a *tracked* work the library no+    /// longer offers and by a memory warning, so a work the sweep processed that+    /// produced no proposals kept its read for the life of the session. The+    /// ledger already answers which works are still live — anything held,+    /// attempted or in flight — so that is the bound, stated once here and+    /// applied after the two places that add to the dictionary.+    private func pruneContexts() {+        let tracked = ledger.trackedWorks+        contexts = contexts.filter { tracked.contains($0.key) }+    }+     /// 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() {@@ -221,6 +291,8 @@ final class CharacterExtractionCoordinator {     /// Held proposals are re-derivable — their revisions are not covered — so     /// they are the first thing to go under memory pressure (Q61).     func memoryWarning() {+        // The retained reads go with the rows they were kept to preview.+        contexts.removeAll()         if ledger.memoryWarning() != nil { attemptTask?.cancel() }     } @@ -237,10 +309,10 @@ final class CharacterExtractionCoordinator {         }         manualOutcomes[workID] = .running -        let candidate: CharacterExtractionCandidate?+        let candidate: ExtractionCandidate?         do {             candidate = try await library-                .characterExtractionCandidates(limit: 1, workIDs: [workID]).first+                .extractionCandidates(limit: 1, workIDs: [workID]).first         } catch {             CharacterExtractionLog.failure(                 "\(workID): manual pass — candidate read failed: \(CharacterExtractionLog.describe(error))")@@ -269,13 +341,14 @@ final class CharacterExtractionCoordinator {     /// `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,+        _ candidate: ExtractionCandidate, pass: ExtractionPassKind,         sweep generation: Int? = nil     ) async {         let context = CharacterExtractionContext(candidate)+        contexts[candidate.workID] = context         let revisions = candidate.revisionsBySource         let sources = candidate.extractionSources(includingCovered: pass == .manual)-        var covered: [CharacterCompletedSource] = []+        var covered: [CompletedSource] = []          for source in sources {             // Q110: the stop signal is checked **per source**, not per work. A@@ -298,13 +371,15 @@ final class CharacterExtractionCoordinator {                 source, context: context, revisions: revisions, pass: pass)             if outcome == .producedNone {                 covered.append(-                    CharacterCompletedSource(ref: source.source, fingerprint: source.fingerprint))+                    CompletedSource(ref: source.source, fingerprint: source.fingerprint))             }         } +        pruneContexts()+         guard !covered.isEmpty else { return }         do {-            let written = try await library.advanceCharacterCoverage(+            let written = try await library.advanceCoverage(                 workID: candidate.workID, sources: covered)             CharacterExtractionLog.note(                 "\(candidate.workID): produced-none covered \(written) of \(covered.count) source(s)")@@ -457,13 +532,19 @@ final class CharacterExtractionCoordinator {      private var currentEnvironment: ModelWorkEnvironment { environment.modelWorkEnvironment } +    /// Design §Diagnostics: a settle line names the kind, so one filtered+    /// Console session says which half of the combined answer survived.     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"+            guard !proposals.isEmpty else { return "no proposals" }+            let byKind = RecordKind.allCases+                .map { kind in "\(proposals.count { $0.kind == kind }) \(kind.rawValue)" }+                .joined(separator: ", ")+            return "\(proposals.count) proposal(s) (\(byKind))"+        case .failed: return "failed"+        case .timedOut: return "timed out"+        case .cancelled: return "cancelled"         }     } 
Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift Modified +2 / -1
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift b/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swiftindex 92252f2..dcf491e 100644--- a/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift+++ b/Asterism/Asterism/CharacterExtraction/CharacterExtractor.swift@@ -54,7 +54,8 @@ actor CharacterExtractor {         let grounded = CharacterGrounding.ground(result, from: source)         for drop in grounded.drops {             CharacterExtractionLog.note(-                "\(CharacterExtractionLog.describe(source.source)): dropped a candidate — \(drop.reason.rawValue)",+                "\(CharacterExtractionLog.describe(source.source)): dropped a \(drop.kind.rawValue) "+                    + "candidate — \(drop.reason.rawValue)",                 content: "name=\"\(drop.name)\"")         }         return (grounded.candidates, started.duration(to: .now))
Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift Modified +353 / -71
diff --git a/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift b/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swiftindex 9112273..6a04c85 100644--- a/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift+++ b/Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift@@ -5,7 +5,8 @@ import Foundation // 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).+// The review list (`character-extraction` Reqs 2.1, 2.2, 2.5, 2.7, 2.8;+// `place-extraction` Reqs 2.1–2.3). // // Modelled on `PostTeachingWorkURLModel`'s queue: a list of independent // decisions, each committed on its own, each failure keeping its own row and@@ -17,6 +18,11 @@ import OSLog // 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 list holds both kinds. A row's *identity* is the kind the pass assembled+// it under and its name key (Q28); the kind it is **displayed** under moves when+// the reader reclassifies it, and everything the decision carries is read off+// the projection that move produces (Q59).  /// One proposed alias on a row, and whether the reader has struck it (Q92). struct CharacterReviewAlias: Identifiable, Equatable, Sendable {@@ -33,34 +39,77 @@ struct CharacterReviewFact: Identifiable, Equatable, Sendable {     let quote: String     let source: SourceRef     var isTicked: Bool-    let fact: CharacterFact+    let fact: RecordFact }  /// One row of the review list: a new candidate, or a bundle of additional-/// content for a character the work already has.+/// content for a record 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.+    /// The proposal key's string form. One row per key per work (Q83/Q100), so+    /// the key identifies the row for every decision that follows — and two+    /// rows of one name key under two kinds are two rows (Q28).     let id: String+    /// The same identity as the coordinator holds it under.+    let key: ProposalKey     let name: String+    /// The kind the row is **displayed** and decided under (Req 2.2).+    let kind: RecordKind+    /// The kind the pass assembled it under — what its identity is made of, and+    /// what a toggle is measured against.+    let originalKind: RecordKind     let isBundle: Bool-    /// The character a bundle enriches, for the existing facts it is shown+    /// Req 2.2: candidates, and the bundles a reclassification produced. A+    /// bundle the pass itself assembled has an existing record on the other end+    /// and is not the reader's to re-file.+    let canReclassify: Bool+    /// Req 1.5's union row: the model returned this name under both kinds, and a+    /// skip decides both (Q23/Q38).+    let isDualKind: Bool+    /// Q41: the *other* kind already holds a record of this name. Presentation+    /// only — matching never crosses kinds (Q13).+    let crossKindHint: String?+    /// The record 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.+    /// The target record's facts as they stand, so the reader can see what the+    /// bundle adds to (Req 2.1). Empty for a candidate.+    let existingFacts: [WorkRecordFactRow]+    /// The held proposal this row was built from — its identity, and the basis+    /// every preview is computed from, so toggling back and forth neither loses+    /// facts nor compounds a re-key.     let proposal: ExtractionProposal+    /// The proposal as the displayed kind leaves it: facts re-keyed to the+    /// previewed target and re-deduped under that kind, aliases and cited+    /// revisions unchanged. **The decision request is built from this**, never+    /// from `proposal` (Q59).+    let projected: ExtractionProposal      var struckAliases: Set<String> { Set(aliases.filter(\.isStruck).map(\.name)) } -    var untickedFacts: Set<CharacterFactIdentity> {+    var untickedFacts: Set<RecordFactIdentity> {         Set(proposedFacts.filter { !$0.isTicked }.map(\.fact.identity))     }++    /// Req 2.2: a reclassified row whose facts all deduped away under the+    /// destination has nothing left to keep. It stays displayed, with the kind+    /// control and with skip as the decision it can still take (Q31).+    ///+    /// A candidate the model returned with **no** facts at all is not that row:+    /// Req 1.2 admits one, and it is the common shape for a place named in+    /// passing. Nothing deduped away, so the name — which is what the reader+    /// wanted when they re-filed it — is still there to keep, and leaving Skip+    /// as the only decision would suppress it.+    ///+    /// An **unstruck alias** counts as content worth keeping too, on Q36's+    /// survival predicate: a reclassified row whose facts all deduped away but+    /// which still offers the destination a name it does not have is a real+    /// addition, and disabling Keep on it threw that alias away.+    var canAccept: Bool {+        kind == originalKind || !proposedFacts.isEmpty || proposal.facts.isEmpty+            || aliases.contains { !$0.isStruck }+    } }  @MainActor @Observable@@ -83,26 +132,36 @@ final class CharacterReviewModel: Identifiable {     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]+    /// Everything a row is drawn and previewed against: both kinds'+    /// presentations, the notes' capture order, and the suppressions the+    /// coordinator's last candidate read left standing.+    private var inputs: ReviewInputs     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+    /// Told which row was decided and which kinds the commit wrote a name-key+    /// suppression under, so the coordinator can drop it — and, for Q56's+    /// both-kinds skip, the row of the same name key it contradicts.+    ///+    /// The second argument is `DecisionRequest.nameKeySuppressedKinds`, the+    /// gated reading (Q77): `suppressedKinds` is never empty and sweeping on it+    /// would discard a legitimate same-name row after an *accept*.+    private let onDecision: @MainActor (ProposalKey, Set<RecordKind>) -> Void     /// Q66: where a `.reRouted` refusal's payload goes. The coordinator-    /// re-points the held row at the character the commit resolved onto, and+    /// re-points the held row at the record the commit resolved onto, and     /// only then is `refresh` worth re-reading.-    private let onReRoute: @MainActor (String, UUID?) -> Void+    private let onReRoute: @MainActor (ProposalKey, UUID?) -> Void+    /// Req 2.2/Q28: the reader's kind and the target the preview resolved, so+    /// the held row remembers both — the choice survives the sheet closing, and+    /// a later settlement's merge keeps it (Q69).+    private let onReclassify: @MainActor (ProposalKey, RecordKind, 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]+    /// The work's last candidate read, as the coordinator retains it. The+    /// reclassify preview's suppression input (Req 2.2), and nil before any+    /// pass has seen the work in this run.+    private let extractionContext: @MainActor () -> CharacterExtractionContext?     /// Whether a row's decision is being committed. One flag for the sheet —     /// decisions commit one save each and the guard in `decide` serialises     /// them — but observable, so the rows show a disabled state instead of the@@ -112,24 +171,30 @@ final class CharacterReviewModel: Identifiable {     init(         workID: UUID,         proposals: [ExtractionProposal],-        characters: [WorkCharacterPresentation],+        characters: [WorkRecordPresentation],+        places: [WorkRecordPresentation] = [],         captureOrder: [UUID: Int] = [:],         library: any LibraryProviding,-        onDecision: @escaping @MainActor (String) -> Void,-        onReRoute: @escaping @MainActor (String, UUID?) -> Void,+        extractionContext: @escaping @MainActor () -> CharacterExtractionContext?,+        onDecision: @escaping @MainActor (ProposalKey, Set<RecordKind>) -> Void,+        onReRoute: @escaping @MainActor (ProposalKey, UUID?) -> Void,+        onReclassify: @escaping @MainActor (ProposalKey, RecordKind, UUID?) -> Void,         refresh: @escaping @MainActor () async -> [ExtractionProposal]     ) {         self.workID = workID-        self.characters = characters-        self.captureOrder = captureOrder+        self.inputs = ReviewInputs(+            characters: characters, places: places, captureOrder: captureOrder,+            suppressedFacts: extractionContext()?.suppressedFacts ?? [:])         self.library = library+        self.extractionContext = extractionContext         self.onDecision = onDecision         self.onReRoute = onReRoute+        self.onReclassify = onReclassify         self.refresh = refresh         // Snapshot at open (Req 2.7).-        self.rows = proposals.map {-            Self.row(from: $0, characters: characters, captureOrder: captureOrder)-        }+        let inputs = self.inputs+        self.rows = proposals.map { Self.row(from: $0, inputs: inputs) }+        syncPreviewedTargets()     }      // MARK: - Reader input@@ -150,6 +215,38 @@ final class CharacterReviewModel: Identifiable {         rows[rowIndex].aliases[aliasIndex].isStruck = isStruck     } +    /// Req 2.2: the reader re-files a candidate under the other kind, and the+    /// row is redrawn **at once** against that kind (Q24) — as a bundle where+    /// the name resolves onto a record the work already has, as a candidate+    /// where it does not.+    ///+    /// The row never leaves the list, whatever the preview finds (Q31), and the+    /// reader's ticks and strikes come with it: a tick is keyed by display row+    /// id, which excludes the name key precisely so a re-keyed fact keeps it+    /// (Q59 — the identity triple would not, which is why the request is built+    /// from the projection rather than matched by identity against the held+    /// row).+    func reclassify(_ rowID: String, to kind: RecordKind) {+        guard let rowIndex = rows.firstIndex(where: { $0.id == rowID }) else { return }+        let row = rows[rowIndex]+        // Idempotent for the kind already displayed: re-deriving it would be a+        // no-op that still rewrote the reader's ticks.+        guard row.canReclassify, row.kind != kind else { return }++        var rebuilt = Self.row(from: row.proposal, displayedAs: kind, inputs: inputs)+        let ticks = Dictionary(+            row.proposedFacts.map { ($0.id, $0.isTicked) }, uniquingKeysWith: { first, _ in first })+        for index in rebuilt.proposedFacts.indices {+            rebuilt.proposedFacts[index].isTicked = ticks[rebuilt.proposedFacts[index].id] ?? true+        }+        let strikes = row.struckAliases+        for index in rebuilt.aliases.indices {+            rebuilt.aliases[index].isStruck = strikes.contains(rebuilt.aliases[index].name)+        }+        rows[rowIndex] = rebuilt+        onReclassify(row.key, kind, rebuilt.targetID)+    }+     /// 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() {@@ -163,39 +260,43 @@ final class CharacterReviewModel: Identifiable {      func skip(_ rowID: String) async { await decide(rowID, action: .skip) } -    private func decide(_ rowID: String, action: CharacterDecisionAction) async {+    private func decide(_ rowID: String, action: DecisionAction) 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(+        // Q59: from the **projection**. Building it from the held row would send+        // a place decision the character kind's fact set.+        let request = row.projected.decisionRequest(             workID: workID, action: action,             struckAliases: row.struckAliases, untickedFacts: row.untickedFacts) -        let outcome: CharacterDecisionOutcome+        let outcome: DecisionOutcome         do {-            outcome = try await library.commitCharacterDecision(request)+            outcome = try await library.commitDecision(request)         } catch {             disclosure = "That could not be saved. Try again."             CharacterExtractionLog.failure(-                "character decision failed — \(CharacterExtractionLog.describe(error))")+                "\(row.kind.rawValue) decision failed — \(CharacterExtractionLog.describe(error))")             return         }          switch outcome {         case .committed:             rows.removeAll { $0.id == rowID }-            onDecision(rowID)+            // Q56/Q77: the gated field, so only a candidate skip can sweep the+            // other kind's row of this name key away with it.+            onDecision(row.key, request.nameKeySuppressedKinds)         case .refused(let refusal):-            await handle(refusal, rowID: rowID, action: action)+            await handle(refusal, row: row, 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+        _ refusal: DecisionRefusal, row: CharacterReviewRow, action: DecisionAction     ) async {         switch refusal {         case .staleSource:@@ -207,53 +308,57 @@ final class CharacterReviewModel: Identifiable {             // 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."+            disclosure = RecordKindPresentation.reRouteDisclosure(row.kind)             // 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)+            onReRoute(row.key, 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."+        case .torn(let recordID):+            disclosure = tornDisclosure(recordID: recordID, kind: row.kind)             routesToCheckLibrary = true         case .workGone:             disclosure = nil             rows = []-            onDecision(rowID)+            onDecision(row.key, [])         }         CharacterExtractionLog.note(-            "character \(action.rawValue) refused — \(String(describing: refusal))",+            "\(row.kind.rawValue) \(action.rawValue) refused — \(String(describing: refusal))",             level: .info)     } +    private func tornDisclosure(recordID: UUID?, kind: RecordKind) -> String {+        guard recordID != nil else {+            return "This work exists in differing copies, so nothing can be added to it yet. "+                + "Open Check Library to choose which copy to keep."+        }+        return RecordKindPresentation.tornRecordDisclosure(kind)+    }+     /// 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+    /// answers, and the work's records come back with the rows: the record 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+            inputs.setRecords(characters: detail.characters, places: detail.places)+            inputs.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+            // record 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")+                "character review refresh could not re-read the work's records")         }-        // Both kept per row, not per value: one row per name key per work+        inputs.suppressedFacts = extractionContext()?.suppressedFacts ?? [:]+        // Both kept per row, not per value: one row per proposal 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.@@ -270,9 +375,9 @@ final class CharacterReviewModel: Identifiable {             uniquingKeysWith: { first, _ in first })         let strikes = Dictionary(             rows.map { ($0.id, $0.struckAliases) }, uniquingKeysWith: { first, _ in first })+        let inputs = self.inputs         rows = held.map { proposal in-            var row = Self.row(-                from: proposal, characters: characters, captureOrder: captureOrder)+            var row = Self.row(from: proposal, inputs: inputs)             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.@@ -285,22 +390,186 @@ final class CharacterReviewModel: Identifiable {             }             return row         }+        syncPreviewedTargets()+    }++    /// Q69: the ledger's merge keeps a reclassified row's `displayedKind` and+    /// the **older** row's previewed target, because a pure value type has no+    /// presentations to preview against. This is where that target is replaced:+    /// the row above was re-previewed over the merged content, and the held row+    /// is told, so the next reconcile judges it against the record the reader is+    /// actually looking at rather than one resolved against an earlier read.+    private func syncPreviewedTargets() {+        for row in rows+        where row.kind != row.originalKind && row.projected.target != row.proposal.target {+            onReclassify(row.key, row.kind, row.targetID)+        }     }      // MARK: - Row construction +    /// Both kinds' presentations and the standing suppressions, as the row+    /// builder needs them. One value rather than four parameters, because a+    /// preview asks all four of them per kind.+    private struct ReviewInputs {+        private(set) var characters: [WorkRecordPresentation]+        private(set) var places: [WorkRecordPresentation]+        var captureOrder: [UUID: Int]+        var suppressedFacts: [RecordKind: Set<RecordFactIdentity>]++        /// The two reductions of a kind's presentations every row asks for,+        /// computed when the presentations are **set** rather than per row.+        /// Building a row matches twice (its own kind and the cross-kind hint)+        /// and dedupes against the accepted facts once, so a sheet of N rows+        /// re-derived both of these 3N times from one unchanged snapshot.+        private var derived: [RecordKind: Derived]++        private struct Derived {+            let targets: [MatchTarget]+            let acceptedFacts: Set<RecordFactIdentity>++            init(_ records: [WorkRecordPresentation]) {+                targets = records.map(ReviewInputs.matchTarget)+                // Accepted identities come from the presentations: the+                // destination's own facts are what a re-keyed proposal fact+                // dedups against (Req 1.6).+                acceptedFacts = Set(records.flatMap(\.facts).map(\.fact.identity))+            }+        }++        init(+            characters: [WorkRecordPresentation] = [],+            places: [WorkRecordPresentation] = [],+            captureOrder: [UUID: Int] = [:],+            suppressedFacts: [RecordKind: Set<RecordFactIdentity>] = [:]+        ) {+            self.characters = characters+            self.places = places+            self.captureOrder = captureOrder+            self.suppressedFacts = suppressedFacts+            derived = [.character: Derived(characters), .place: Derived(places)]+        }++        /// The one way the presentations move, so the derivations cannot be+        /// left behind by an assignment that forgot them.+        mutating func setRecords(+            characters: [WorkRecordPresentation], places: [WorkRecordPresentation]+        ) {+            self = ReviewInputs(+                characters: characters, places: places, captureOrder: captureOrder,+                suppressedFacts: suppressedFacts)+        }++        func records(of kind: RecordKind) -> [WorkRecordPresentation] {+            switch kind {+            case .character: characters+            case .place: places+            }+        }++        /// Req 2.3's tiers, run over one kind's presentations —+        /// `RecordMatching` itself, because the commit re-runs the same tiers+        /// against the store and the two answers have to be one answer.+        func match(_ nameKey: String, of kind: RecordKind) -> WorkRecordPresentation? {+            guard let target = RecordMatching.match(+                nameKey: nameKey, among: derived[kind]?.targets ?? [])+            else { return nil }+            return records(of: kind).first { $0.id == target.id }+        }++        /// Tornness is not a matching input — the commit gate owns it (Req 2.8)+        /// — but it is carried so the target reads the same either side.+        static func matchTarget(_ record: WorkRecordPresentation) -> MatchTarget {+            MatchTarget(+                id: record.id,+                currentNameKey: RecordNameKey.normalize(record.name),+                retainedKey: record.nameKey,+                aliasKeys: record.aliases.map(RecordNameKey.normalize),+                isTorn: record.isTorn)+        }++        /// Accepted identities come from the presentations: the destination's+        /// own facts are what a re-keyed proposal fact dedups against (Req 1.6).+        func acceptedFacts(of kind: RecordKind) -> Set<RecordFactIdentity> {+            derived[kind]?.acceptedFacts ?? []+        }++        /// Suppressed identities come from the coordinator's retained read. A+        /// stale index affects the preview and nothing else: the commit dedups+        /// against the store, and accepting a ticked fact clears its+        /// suppression (Req 2.4).+        func suppressedFacts(of kind: RecordKind) -> Set<RecordFactIdentity> {+            suppressedFacts[kind] ?? []+        }+    }++    /// The proposal as `kind` leaves it (Q59): the target that kind's records+    /// resolve, and the facts re-keyed to it and re-deduped under it.+    ///+    /// Aliases and cited revisions are untouched — an alias is the reader's to+    /// strike and a revision is what staleness is judged against, and neither is+    /// a statement about the kind.+    private static func project(+        _ proposal: ExtractionProposal, to kind: RecordKind, inputs: ReviewInputs+    ) -> ExtractionProposal {+        let target = inputs.match(proposal.nameKey, of: kind)+        let resolvedKey = target?.nameKey ?? proposal.nameKey+        let accepted = inputs.acceptedFacts(of: kind)+        let suppressed = inputs.suppressedFacts(of: kind)++        var facts: [GroundedFact] = []+        var seen: Set<RecordFactIdentity> = []+        for fact in proposal.facts {+            let keyed = fact.keyed(to: resolvedKey)+            guard seen.insert(keyed.identity).inserted else { continue }+            guard !accepted.contains(keyed.identity), !suppressed.contains(keyed.identity) else {+                continue+            }+            facts.append(keyed)+        }++        var projected = proposal+        projected.displayedKind = kind+        projected.target = target.map { .existing($0.id) } ?? .newRecord+        projected.facts = facts+        return projected+    }+     private static func row(-        from proposal: ExtractionProposal, characters: [WorkCharacterPresentation],-        captureOrder: [UUID: Int]+        from proposal: ExtractionProposal, displayedAs displayedKind: RecordKind? = nil,+        inputs: ReviewInputs     ) -> 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 } }+        let displayed = displayedKind ?? proposal.displayedKind+        // A row nobody has reclassified is shown exactly as the pass assembled+        // it: the assembler resolved it against the store under a lock, and+        // re-previewing it here would answer the same question from a snapshot.+        // Once a kind has moved, the held target was written by a preview and+        // only a preview can move it back — including a merged row's, which the+        // ledger carried over from the older row (Q69).+        let isPristine = proposal.displayedKind == proposal.kind+        let projected = isPristine && displayed == proposal.kind+            ? proposal+            : project(proposal, to: displayed, inputs: inputs)++        let targetID: UUID? = if case .existing(let id) = projected.target { id } else { nil }+        let target = targetID.flatMap { id in inputs.records(of: displayed).first { $0.id == id } }+        let captureOrder = inputs.captureOrder+         return CharacterReviewRow(-            id: proposal.nameKey,-            // A bundle is shown under the character's own name, not the model's+            id: proposal.key.rowID,+            key: proposal.key,+            // A bundle is shown under the record'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,+            kind: displayed,+            originalKind: proposal.kind,+            isBundle: projected.isBundle,+            // Req 2.2: a candidate always, a bundle only where a+            // reclassification produced it — which is exactly where the+            // displayed kind has moved off the assembled one.+            canReclassify: !proposal.isBundle || displayed != proposal.kind,+            isDualKind: proposal.isDualKind,+            crossKindHint: hint(for: proposal.nameKey, displayedAs: displayed, inputs: inputs),             targetID: targetID,             aliases: proposal.proposedAliases.map {                 CharacterReviewAlias(name: $0, isStruck: false)@@ -308,9 +577,9 @@ final class CharacterReviewModel: Identifiable {             // 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+            // reader reads a record's facts as history, and the work page shows+            // exactly this order once they are kept.+            proposedFacts: projected.facts                 .map { fact in                     let stored = fact.storedFact                     return CharacterReviewFact(@@ -326,6 +595,19 @@ final class CharacterReviewModel: Identifiable {                     return left.statement < right.statement                 },             existingFacts: target?.facts ?? [],-            proposal: proposal)+            proposal: proposal,+            projected: projected)+    }++    /// Q41: the model files places as characters often enough that the reader's+    /// one-tap correction needs a reason to be offered. The hint says the other+    /// kind already answers to this name; it changes no matching and commits+    /// nothing.+    private static func hint(+        for nameKey: String, displayedAs kind: RecordKind, inputs: ReviewInputs+    ) -> String? {+        let other = kind.other+        guard inputs.match(nameKey, of: other) != nil else { return nil }+        return RecordKindPresentation.existingNameHint(other)     } }
Asterism/Asterism/UITestLaunchSupport.swift Modified +75 / -24
diff --git a/Asterism/Asterism/UITestLaunchSupport.swift b/Asterism/Asterism/UITestLaunchSupport.swiftindex 8ee2b5f..9ddaf4b 100644--- a/Asterism/Asterism/UITestLaunchSupport.swift+++ b/Asterism/Asterism/UITestLaunchSupport.swift@@ -180,33 +180,82 @@ enum UITestLaunchSupport {     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.+    /// The characters and places 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.+    ///+    /// Three shapes the place journey needs, all of them assembled rather than+    /// asserted here:+    ///+    /// - **Kestrel Head** is a place and nothing else: its quote sits verbatim+    ///   in the fixture's notes and its name is capitalised there, so it passes+    ///   grounding's capital-letter rule (Q42) and lands in the Places section.+    /// - **Selkie** is returned under *both* kinds with no existing record of+    ///   either, which is Req 1.5's union row: one row, displayed as a+    ///   character (Q20), carrying both copies' facts and disclosing that a+    ///   skip decides both (Q38).+    /// - **Ada/Nightjar** and **Brede** are the character-only rows the+    ///   pre-place journey already ran over, unchanged.     ///     /// 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 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")+                ]),+            ExtractedCharacter(+                name: "Selkie",+                facts: [+                    ExtractedFact(+                        statement: "Selkie is moored below the light.",+                        quote: "Selkie is moored below")+                ]),+        ],+        places: [+            ExtractedPlace(+                name: "Kestrel Head",+                facts: [+                    ExtractedPlaceFact(+                        statement: "The lighthouse stands at Kestrel Head.",+                        quote: "the lighthouse at Kestrel Head"),+                    // Grounds in the **chapter note** rather than the work's+                    // generic notes, so the kept place holds one fact with a+                    // live citation — which is the only way the entry detail's+                    // Places section (Req 4.4) has anything to draw.+                    ExtractedPlaceFact(+                        statement: "The tender passes Kestrel Head.",+                        quote: "past Kestrel Head"),+                ]),+            ExtractedPlace(+                name: "Selkie",+                facts: [+                    // Deliberately *not* the character copy's wording: the union+                    // row shows both copies' facts, and two identical lines+                    // would read as a display bug rather than as one name the+                    // model filed under both kinds. The quote is the fixture's+                    // text and stays verbatim.+                    ExtractedPlaceFact(+                        statement: "Selkie's mooring lies below the light.",+                        quote: "moored below the light")+                ]),+        ])      static func characterExtractionClient(         environmentProvider: any ProcessEnvironmentProviding = SystemProcessEnvironment()@@ -215,7 +264,9 @@ enum UITestLaunchSupport {         case "canned":             return StubCharacterExtractionModelClient(result: cannedExtractionResult)         case "empty":-            return StubCharacterExtractionModelClient(result: ExtractionResult(characters: []))+            // Both arrays empty: the source grounds to nothing of either kind+            // and covers at pass time (Req 1.4).+            return StubCharacterExtractionModelClient(result: ExtractionResult())         case "unavailable":             return StubCharacterExtractionModelClient(                 availability: .unavailable(reason: "UI test"))
Asterism/Asterism/ViewModels/AppLibraryModel.swift Modified +19 / -10
diff --git a/Asterism/Asterism/ViewModels/AppLibraryModel.swift b/Asterism/Asterism/ViewModels/AppLibraryModel.swiftindex 3725159..130c6bf 100644--- a/Asterism/Asterism/ViewModels/AppLibraryModel.swift+++ b/Asterism/Asterism/ViewModels/AppLibraryModel.swift@@ -157,8 +157,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 BackupV11SnapshotProviding).-    private var backupRepository: (any BackupV11SnapshotProviding)?+    /// Retains the concrete repository for backup export (conforms to BackupV12SnapshotProviding).+    private var backupRepository: (any BackupV12SnapshotProviding)?     /// 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.@@ -2083,7 +2083,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 = BackupV11Exporter(+        let exporter = BackupV12Exporter(             repository: repo,             stagingDirectory: stagingDir         )@@ -2196,21 +2196,29 @@ public final class AppLibraryModel {         }     } -    /// `character-extraction`: one work whose notes name people, so the-    /// extraction journey has something real to ground against.+    /// `character-extraction`: one work whose notes name people and places, 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.+    /// Q92). `place-extraction` adds two things to the same sentence set — a+    /// capitalised place name (Kestrel Head) that passes the capital-letter+    /// rule (Q42), and a name the scripted answer returns under both kinds+    /// (Selkie), which assembles into Req 1.5's union row. 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."))+            // The place half of the sentence is what gives a *place* fact a+            // live citation: Kestrel Head's other quote sits in the work's+            // generic notes, which cite no entry, so without this the entry+            // detail's Places section (Req 4.4) could never be reached.+            note: "Brede rows the tender out at dusk, past Kestrel Head."))          let work = try await repo.createWork(             NewWorkDraft(displayTitle: "The Lamp Room", hostname: "characters.test"))@@ -2228,8 +2236,9 @@ public final class AppLibraryModel {                 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.",+                genericNotes: "Ada keeps the lighthouse at Kestrel Head, and the crew call "+                    + "her Nightjar. Brede rows the tender out at dusk. Selkie is moored "+                    + "below the light.",                 workStatus: reloaded.workStatus, readingStatus: reloaded.readingStatus,                 verdict: reloaded.verdict,                 membership: reloaded.membership))
Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift Modified +10 / -1
diff --git a/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift b/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swiftindex f902347..5a6bcb9 100644--- a/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift+++ b/Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift@@ -79,11 +79,20 @@ public final class DuplicateResolutionModel {     /// 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] {+    public var characterVariants: [RecordVariantChoice] {         if case .character(_, let variants, _, _) = contract { return variants }         return []     } +    /// `place-extraction` Req 5.3: the same, for a torn place. Its own accessor+    /// rather than one folded onto `characterVariants`, so the sheet's two arms+    /// stay as separate as the contract's are and neither can draw the other's+    /// set.+    public var placeVariants: [RecordVariantChoice] {+        if case .place(_, let variants, _, _) = contract { return variants }+        return []+    }+     public var differingFields: [DuplicateResolutionField] { contract?.differingFields ?? [] }      public var canConfirm: Bool {
Asterism/Asterism/ViewModels/EntryDetailModel.swift Modified +10 / -1
diff --git a/Asterism/Asterism/ViewModels/EntryDetailModel.swift b/Asterism/Asterism/ViewModels/EntryDetailModel.swiftindex 63bcde5..e874f91 100644--- a/Asterism/Asterism/ViewModels/EntryDetailModel.swift+++ b/Asterism/Asterism/ViewModels/EntryDetailModel.swift@@ -144,10 +144,19 @@ public final class EntryDetailModel {     ///     /// Empty is the ordinary case, and what makes the section absent rather than     /// empty.-    public var citingCharacters: [EntryCitingCharacter] {+    public var citingCharacters: [EntryCitingRecord] {         teachingDetail?.citingCharacters ?? []     } +    /// `place-extraction` Req 4.4: the places holding a fact that cites this+    /// entry, in name order — a section of its own beside the characters (Q16).+    ///+    /// Off the same teaching detail and so the same locked read: two sections+    /// about one note must not be able to describe two different moments.+    public var citingPlaces: [EntryCitingRecord] {+        teachingDetail?.citingPlaces ?? []+    }+     /// Whether this entry's Site retains rules that failed validation (Q39,     /// T-1949). This used to fail the whole screen — `.quarantined` was thrown     /// out of `entryTeachingDetail` and this Entry looked deleted (`Unavailability
Asterism/Asterism/ViewModels/MaintenanceViewModels.swift Modified +11 / -2
diff --git a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swiftindex cf36033..0f506ca 100644--- a/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift+++ b/Asterism/Asterism/ViewModels/MaintenanceViewModels.swift@@ -441,14 +441,23 @@ 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 {-        // Three record types now, not two (`character-extraction` Req 6.5): the+        // Four record types now (`place-extraction` Req 3.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")+        // Through `RecordKindPresentation`, which is where a record kind becomes+        // words everywhere else.+        case .character:+            (noun, plural) = (+                RecordKindPresentation.noun(.character), RecordKindPresentation.plural(.character)+            )+        case .place:+            (noun, plural) = (+                RecordKindPresentation.noun(.place), RecordKindPresentation.plural(.place)+            )         // 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.
Asterism/Asterism/ViewModels/SettingsBackupModel.swift Modified +13 / -13
diff --git a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift b/Asterism/Asterism/ViewModels/SettingsBackupModel.swiftindex c599a71..8698d7d 100644--- a/Asterism/Asterism/ViewModels/SettingsBackupModel.swift+++ b/Asterism/Asterism/ViewModels/SettingsBackupModel.swift@@ -5,20 +5,20 @@ import OSLog // MARK: - Backup Exporting Protocol  /// Test seam abstracting the exporter's operations needed by the Settings-/// surface. Conforms `BackupV11Exporter` to this protocol via extension below.+/// surface. Conforms `BackupV12Exporter` to this protocol via extension below. ///-/// Settings exports 11/12 (`work-creators` Req 9.1): the archive-/// carries a Work's site memberships, the reader's dismissed pairs, citations-/// that name a rule by UUID alone, and the Work's two statuses and verdict. It-/// is also the only format the app reads, so there is one exporter and one-/// importer.+/// Settings exports 12/13 (`place-extraction` Req 5.1): the archive+/// carries the reader's named places and their suppressions, a Work's site+/// memberships, the reader's dismissed pairs, citations that name a rule by UUID+/// alone, and the Work's two statuses and verdict. It is also the only format+/// the app reads, so there is one exporter and one importer. public protocol BackupExporting: Sendable {-    func export(metadata: BackupV11Metadata) async throws -> BackupExportResult+    func export(metadata: BackupV12Metadata) async throws -> BackupExportResult     func cleanup(_ result: BackupExportResult)     func scavengeStaleFiles() } -extension BackupV11Exporter: BackupExporting {}+extension BackupV12Exporter: BackupExporting {}  // MARK: - Settings Backup View Model @@ -79,7 +79,7 @@ public final class SettingsBackupModel {         currentResult = nil          do {-            let metadata = BackupV11Metadata(+            let metadata = BackupV12Metadata(                 appBuild: Self.currentAppBuild(),                 exportedAt: Date()             )@@ -92,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? BackupV11ExportError,+            if let exportError = error as? BackupV12ExportError,                case .tornGroups = exportError {                 routesToCheckLibrary = true             }@@ -140,7 +140,7 @@ public final class SettingsBackupModel {         switch error {         case is BackupCodecError:             "Backup export failed due to an encoding error. Please try again."-        case let error as BackupV11ExportError:+        case let error as BackupV12ExportError:             exportMessage(for: error)         default:             "Backup export failed. Please try again."@@ -164,7 +164,7 @@ public final class SettingsBackupModel {     /// (`character-extraction` Req 6.5, Q105). It needs no new sentence: the     /// payload carries a count and a route, not a record kind, and Check Library     /// is where every torn group is resolved.-    private static func exportMessage(for error: BackupV11ExportError) -> String {+    private static func exportMessage(for error: BackupV12ExportError) -> String {         switch error {         case .tornGroups(let payload):             tornGroupsMessage(payload)@@ -204,7 +204,7 @@ public final class SettingsBackupModel {         switch error {         case let e as BackupCodecError:             "codec: \(e)"-        case let e as BackupV11ExportError:+        case let e as BackupV12ExportError:             "export: \(e)"         case let e as LibraryRepositoryError:             "repository: \(e)"
Asterism/Asterism/ViewModels/WorkDetailModel.swift Modified +275 / -112
diff --git a/Asterism/Asterism/ViewModels/WorkDetailModel.swift b/Asterism/Asterism/ViewModels/WorkDetailModel.swiftindex b97dda4..c76737e 100644--- a/Asterism/Asterism/ViewModels/WorkDetailModel.swift+++ b/Asterism/Asterism/ViewModels/WorkDetailModel.swift@@ -117,38 +117,61 @@ public final class WorkDetailModel {     /// 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 ?? [] }+    public var characters: [WorkRecordPresentation] { presentation?.characters ?? [] }++    /// The work's places, on the same terms (`place-extraction` Req 4.1). The+    /// review sheet previews a reclassification against them (Req 2.3); the+    /// Places section itself is task 23's.+    public var places: [WorkRecordPresentation] { presentation?.places ?? [] }      /// 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.+    /// The reader's intended state for each record of **either** kind, keyed by+    /// id (`place-extraction` design §Edit session).+    ///+    /// One draft set with a kind on each draft, rather than one set per table:+    /// a conversion is then a field on a draft the session already holds, and+    /// the two cards are filtered views of one dictionary.     ///     /// 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] = [:]+    public private(set) var recordDrafts: [UUID: RecordDraft] = [:]     /// What each draft is compared against, and the basis the commit re-verifies-    /// (Q73).-    private var characterBases: [UUID: CharacterEditBasis] = [:]+    /// (Q73). A basis states the kind the record is stored under, which a+    /// converted draft no longer agrees with — that disagreement *is* the+    /// staged conversion.+    private var recordBases: [UUID: RecordEditBasis] = [:]     /// 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+    private var stagedRecordOperations: [StagedRecordOperation] = []+    /// Whether the last refused record step has somewhere for the reader to     /// go — only a torn refusal does (Req 6.5).-    public private(set) var characterRefusalRoutesToCheckLibrary = false+    public private(set) var recordRefusalRoutesToCheckLibrary = 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 {+    /// Creates, combines and conversions 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 StagedRecordOperation: Equatable {         case create(id: UUID)         case delete(id: UUID)         case combine(source: UUID, target: UUID)+        /// `place-extraction` Req 3.7: the destination kind is what the reader+        /// chose; the draft rides on it at Save (Q54).+        case convert(id: UUID, to: RecordKind)+    }++    /// One line in an edit-mode collection card: which record, what its draft+    /// holds, and whether the stored row is torn.+    public struct RecordEditLine: Identifiable, Equatable, Sendable {+        public let id: UUID+        public let draft: RecordDraft+        public let isTorn: Bool     }      /// Req 2.8, mirrored from Entry detail: a torn Work's authored fields are@@ -401,7 +424,7 @@ public final class WorkDetailModel {             restoreCreditDraftFromPresentation()             await reloadRoleOptions()             seedLinkTypeDrafts(detail.links)-            adoptCharacterDrafts(detail.characters)+            adoptRecordDrafts(detail.characters + detail.places)             // Before the projection below, which is *about* the selected site.             resetWorkURLHostnameIfNeeded()             state = .ready@@ -1287,7 +1310,7 @@ public final class WorkDetailModel {     /// 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+        // `adoptRecordDrafts` refuses to replace the drafts of a session that         // is still open.         isEditing = false         restoreDraftsFromSnapshot()@@ -1327,30 +1350,38 @@ public final class WorkDetailModel {             guard await commitWorkURLDraft() else { return }             committedURL = true         }-        // Derived once. It JSON-encodes every character's facts to decide what+        // Derived once. It JSON-encodes every record'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+        // commit: `adoptRecordDrafts` refuses to touch a staged session, and+        // the metadata save below skips its reload when a record step follows.+        let recordOperations = stagedRecordEdits()+        let hasRecordChanges = !recordOperations.isEmpty         let hadMetadataChanges = hasUnsavedChanges          if hadMetadataChanges {-            // One reload, not two: the character step is the last write, so when+            // One reload, not two: the record step is the last write, so when             // one follows, its reload is the one that publishes everything.-            await save(reloading: !hasCharacterChanges)+            await save(reloading: !hasRecordChanges)             // Both halves of a refusal keep the editor open: the sentence that             // has no field to sit under, and the one that sits under the             // position field.             guard errorMessage == nil, positionRefusal == nil else { return }         }-        guard await commitCharacterStep(characterOperations) else { return }+        guard await commitRecordStep(recordOperations) 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) {+        if hasRecordChanges {+            // `place-extraction` Q55: the app is told **before** the reload. The+            // coordinator's `reconcile()` is reached only through+            // `refreshDiagnosesAndSnapshots`, and a held bundle targeting a+            // record this step deleted or converted has to go with it. Without+            // this the bundle stood until the next arrival — harmless, because+            // the commit gate refuses it, but visible.+            await onMutation()+        }+        if hasRecordChanges || (committedURL && !hadMetadataChanges) {             await load()         }     }@@ -1376,7 +1407,7 @@ public final class WorkDetailModel {         // The session that could have auto-reverted is over.         autoRevertedReading = false         finishedReadingPrompt = nil-        adoptCharacterDrafts(presentation?.characters ?? [])+        adoptRecordDrafts((presentation?.characters ?? []) + (presentation?.places ?? []))     }      // MARK: - The finished-reading rule (Decision 1, Reqs 3.1–3.5)@@ -1475,54 +1506,94 @@ public final class WorkDetailModel {         if prompt.thenCommits { await commitEditing() }     } -    // MARK: - The character edit session (Reqs 3.2, 3.7, 5.3)+    // MARK: - The record edit session (Reqs 3.2, 3.7, 5.3; `place-extraction` 3.2, 3.7) -    /// 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.+    /// Whether the reader has a record edit session in progress — staged+    /// creates, deletes, combines or conversions (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 {+    private var hasStagedRecordSession: 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 }+        if !stagedRecordOperations.isEmpty { return true }+        return recordDrafts.contains { id, draft in+            guard let basis = recordBases[id] else { return false }             return Self.differs(draft, from: basis)         }     } -    private func adoptCharacterDrafts(_ characters: [WorkCharacterPresentation]) {+    private func adoptRecordDrafts(_ records: [WorkRecordPresentation]) {         // 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)))+        guard !hasStagedRecordSession else { return }+        recordDrafts = Dictionary(+            uniqueKeysWithValues: records.map { record in+                (record.id,+                 RecordDraft(+                    kind: record.kind, name: record.name, note: record.note,+                    aliases: record.aliases, facts: record.facts.map(\.fact)))             })-        characterBases = Dictionary(-            uniqueKeysWithValues: characters.map { ($0.id, $0.editBasis) })-        stagedCharacterOperations = []-        characterRefusalRoutesToCheckLibrary = false+        recordBases = Dictionary(+            uniqueKeysWithValues: records.map { ($0.id, $0.editBasis) })+        stagedRecordOperations = []+        recordRefusalRoutesToCheckLibrary = false+    }++    public func recordDraft(for id: UUID) -> RecordDraft? { recordDrafts[id] }++    /// The two cards' filtered views of the one draft set. A converted record+    /// leaves one and joins the other the moment the reader taps, which is what+    /// makes the correction visible before it is written.+    public var characterDrafts: [UUID: RecordDraft] { recordDrafts.filter { $0.value.kind == .character } }+    public var placeDrafts: [UUID: RecordDraft] { recordDrafts.filter { $0.value.kind == .place } }++    /// The records of one kind, as the page shows them.+    private func presentedRecords(of kind: RecordKind) -> [WorkRecordPresentation] {+        switch kind {+        case .character: characters+        case .place: places+        }+    }++    /// The stored record of either kind with this id, or nil for one this+    /// session created.+    private func presentedRecord(id: UUID) -> WorkRecordPresentation? {+        characters.first { $0.id == id } ?? places.first { $0.id == id }     } -    public func characterDraft(for id: UUID) -> CharacterDraft? { characterDrafts[id] }+    /// One collection card's lines: the stored records whose **draft** is of+    /// this kind, in the order the read gave them, then the ones this session+    /// created, in the order the reader added them.+    ///+    /// Keyed off the draft rather than off the presentation, because that is+    /// the whole of what a staged conversion changes on this screen.+    public func editLines(of kind: RecordKind) -> [RecordEditLine] {+        var lines = (characters + places).compactMap { record -> RecordEditLine? in+            guard let draft = recordDrafts[record.id], draft.kind == kind else { return nil }+            return RecordEditLine(id: record.id, draft: draft, isTorn: record.isTorn)+        }+        lines += createdRecordIDs(of: kind).compactMap { id in+            recordDrafts[id].map { RecordEditLine(id: id, draft: $0, isTorn: false) }+        }+        return lines+    } -    /// The characters this session created, in the order the reader added them.+    /// The records of one kind 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 }+    /// The *draft's* kind decides which card a new row sits on, so+    /// create-then-convert moves it without a second operation (Q54).+    public func createdRecordIDs(of kind: RecordKind) -> [UUID] {+        stagedRecordOperations.compactMap { staged in+            guard case .create(let id) = staged, recordDrafts[id]?.kind == kind else { return nil }             return id         }     }@@ -1533,97 +1604,174 @@ public final class WorkDetailModel {     /// 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 {+    /// Req 5.3's read-only gate, per record: a torn record is read-only until+    /// its resolution, and so is every record while the *work* is torn.+    public func canEditRecord(id: UUID) -> Bool {         guard !isReadOnly else { return false }-        return characters.first { $0.id == id }?.isTorn == false+        return presentedRecord(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) }+    /// Whether this record has a conversion staged (`place-extraction` Req 3.7).+    public func isConverted(id: UUID) -> Bool { convertedRecordIDs.contains(id) }++    /// The staged conversions as a set. A session holds a handful of+    /// operations, but `combineTargets` asks `isConverted` once per candidate,+    /// so the linear scan was quadratic in the work's records for no reason.+    private var convertedRecordIDs: Set<UUID> {+        Set(stagedRecordOperations.compactMap { staged in+            if case .convert(let id, _) = staged { return id }+            return nil+        })     } -    public func updateCharacterDraft(id: UUID, _ edit: (inout CharacterDraft) -> Void) {-        guard var draft = characterDrafts[id] else { return }+    /// Who this record may be combined into (Req 3.7): every other editable+    /// record **of its own kind**. A torn record refuses on either side, so it+    /// is offered on neither; a converted record and one created in this+    /// session are offered on neither either, because the UUID a combine names+    /// is one the commit has not minted yet (Q54).+    public func combineTargets(for id: UUID) -> [WorkRecordPresentation] {+        let converted = convertedRecordIDs+        guard canEditRecord(id: id), !converted.contains(id),+              let kind = recordDrafts[id]?.kind+        else { return [] }+        return presentedRecords(of: kind).filter {+            $0.id != id && canEditRecord(id: $0.id) && !converted.contains($0.id)+        }+    }++    public func updateRecordDraft(id: UUID, _ edit: (inout RecordDraft) -> Void) {+        guard var draft = recordDrafts[id] else { return }         edit(&draft)-        characterDrafts[id] = draft+        recordDrafts[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+    public func deleteFact(_ identity: RecordFactIdentity, from id: UUID) {+        updateRecordDraft(id: id) { draft in             draft.facts.removeAll { $0.identity == identity }         }     }      @discardableResult-    public func addCharacter(named name: String) -> UUID {+    public func addCharacter(named name: String) -> UUID { addRecord(.character, named: name) }++    /// `place-extraction` Req 3.2's "Add a place", on the "Add a character"+    /// footer's terms.+    @discardableResult+    public func addPlace(named name: String) -> UUID { addRecord(.place, named: name) }++    /// The body both wrappers above share, and what a caller holding a `kind`+    /// calls directly rather than branching on it.+    @discardableResult+    public func addRecord(_ kind: RecordKind, 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))+        recordDrafts[id] = RecordDraft(kind: kind, name: name)+        stagedRecordOperations.append(.create(id: id))         return id     } -    public func deleteCharacter(id: UUID) {-        characterDrafts[id] = nil-        if let index = stagedCharacterOperations.firstIndex(of: .create(id: id)) {+    public func deleteRecord(id: UUID) {+        recordDrafts[id] = nil+        if let index = stagedRecordOperations.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)+            stagedRecordOperations.remove(at: index)             return         }-        stagedCharacterOperations.append(.delete(id: id))+        // Q54: converted and then deleted in one session is a plain delete —+        // the conversion never happened, so the row goes under the kind it is+        // stored as.+        stagedRecordOperations.removeAll { staged in+            if case .convert(let converted, _) = staged { return converted == id }+            return false+        }+        stagedRecordOperations.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))+    public func combineRecord(source: UUID, into target: UUID) {+        // The offered list is the gate: it already refuses a torn record on+        // either side, a target of the other kind, and anything converted or+        // created in this session.+        guard combineTargets(for: source).contains(where: { $0.id == target }) else { return }+        recordDrafts[source] = nil+        stagedRecordOperations.append(.combine(source: source, target: target))+    }++    /// `place-extraction` Req 3.7, Decision 1: the reader's correction of a+    /// record filed under the wrong kind, **staged** like every other+    /// structural action and applied by the session's one Save.+    ///+    /// The draft's kind flips at once, so the line moves cards under the+    /// reader's finger; tapping again takes the conversion back. A record this+    /// session created is not converted at all — it is created under the other+    /// kind, which is what the flipped draft already says (Q54).+    public func convertRecord(id: UUID) {+        guard let draft = recordDrafts[id] else { return }+        // A created record has no stored row, so no basis and no tornness; the+        // gate is the work's alone.+        let isCreated = recordBases[id] == nil+        guard isCreated ? !isReadOnly : canEditRecord(id: id) else { return }++        if let index = stagedRecordOperations.firstIndex(where: { staged in+            if case .convert(let converted, _) = staged { return converted == id }+            return false+        }) {+            stagedRecordOperations.remove(at: index)+            recordDrafts[id]?.kind = recordBases[id]?.kind ?? draft.kind+            return+        }++        let destination = draft.kind.other+        recordDrafts[id]?.kind = destination+        guard !isCreated else { return }+        stagedRecordOperations.append(.convert(id: id, to: destination))     }      /// The session's operations, in the order the reader performed them.     ///-    /// Updates and deletes of characters that already existed are derived here+    /// Updates and deletes of records 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] = []+    /// update, and the draft is the intended state either way. Creates,+    /// combines and conversions carry their own position, because their order+    /// against each other is what Q97 is about.+    private func stagedRecordEdits() -> [RecordEditOperation] {+        var operations: [RecordEditOperation] = []         var handled: Set<UUID> = []-        for staged in stagedCharacterOperations {+        for staged in stagedRecordOperations {             switch staged {             case .create(let id):-                guard let draft = characterDrafts[id] else { continue }+                guard let draft = recordDrafts[id] else { continue }                 operations.append(.create(draft))                 handled.insert(id)             case .delete(let id):-                guard let basis = characterBases[id] else { continue }+                guard let basis = recordBases[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]+                guard let sourceBasis = recordBases[source],+                      let targetBasis = recordBases[target]                 else { continue }                 operations.append(.combine(source: sourceBasis, target: targetBasis))                 handled.insert(source)+            case .convert(let id, let destination):+                // Q54: the draft as it stands at Save rides on the convert, so+                // an edit made after it needs no `.update` naming a UUID the+                // commit has not minted yet.+                guard let basis = recordBases[id], let draft = recordDrafts[id] else { continue }+                operations.append(.convert(basis: basis, to: destination, draft: draft))+                handled.insert(id)             }         }-        for (id, draft) in characterDrafts.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {-            guard !handled.contains(id), let basis = characterBases[id] else { continue }+        for (id, draft) in recordDrafts.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {+            guard !handled.contains(id), let basis = recordBases[id] else { continue }             guard Self.differs(draft, from: basis) else { continue }             operations.append(.update(basis: basis, draft: draft))         }@@ -1631,32 +1779,33 @@ public final class WorkDetailModel {     }      /// A draft differs from its basis when the basis it *would* produce differs.-    /// Comparing through `CharacterEditBasis` rather than field by field is what+    /// Comparing through `RecordEditBasis` 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,+    private static func differs(_ draft: RecordDraft, from basis: RecordEditBasis) -> Bool {+        RecordEditBasis(+            kind: basis.kind, recordID: basis.recordID, name: draft.name, note: draft.note,             aliases: draft.aliases, facts: draft.facts) != basis     } -    /// The one repository call the session's character changes go through.+    /// The one repository call the session's record changes go through, of+    /// either kind and conversions included (Q47).     /// Returns false where the step refused, which keeps the editor open.-    private func commitCharacterStep(_ operations: [CharacterEditOperation]) async -> Bool {+    private func commitRecordStep(_ operations: [RecordEditOperation]) async -> Bool {         guard !operations.isEmpty else { return true }-        characterRefusalRoutesToCheckLibrary = false+        recordRefusalRoutesToCheckLibrary = false         do {-            let outcome = try await library.commitCharacterEdits(+            let outcome = try await library.commitRecordEdits(                 workID: workID, operations: operations)             switch outcome {             case .committed:-                stagedCharacterOperations = []+                stagedRecordOperations = []                 return true             case .refused(let refusal):-                errorMessage = Self.characterRefusalMessage(refusal)-                if case .torn = refusal { characterRefusalRoutesToCheckLibrary = true }-                if case .workTorn = refusal { characterRefusalRoutesToCheckLibrary = true }+                errorMessage = recordRefusalMessage(refusal)+                if case .torn = refusal { recordRefusalRoutesToCheckLibrary = true }+                if case .workTorn = refusal { recordRefusalRoutesToCheckLibrary = true }                 state = .error(message: errorMessage ?? "")                 return false             }@@ -1664,14 +1813,14 @@ public final class WorkDetailModel {             errorMessage = error.localizedDescription             state = .error(message: error.localizedDescription)             Self.logger.error(-                "Character edit step failed: \(String(describing: error), privacy: .public)")+                "Record edit step failed: \(String(describing: error), privacy: .public)")             return false         }     } -    /// Q73: the whole step refuses, and the message names the character so the+    /// Q73: the whole step refuses, and the message names the record so the     /// reader knows which of their edits is the one in question.-    private static func characterRefusalMessage(_ refusal: CharacterEditRefusal) -> String {+    private func recordRefusalMessage(_ refusal: RecordEditRefusal) -> String {         switch refusal {         case .basisMismatch(_, let name):             "\(name) changed elsewhere while you were editing, so nothing was saved. "@@ -1685,18 +1834,32 @@ public final class WorkDetailModel {             // 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 .recordGone(let recordID):+            // The noun is the record's own: a place reported as a deleted+            // character would send the reader to the wrong card.+            "That \(RecordKindPresentation.noun(storedKind(of: recordID))) was deleted "+                + "elsewhere, so nothing was saved."         case .workGone:             "This work was deleted elsewhere, so nothing was saved."+        case .kindMismatch:+            // Unreachable from these views: the editor knows its own kind and+            // the combine target list is per kind. Worded for the reader all the+            // same, because a message the reader cannot act on still beats none.+            "That record's kind changed while you were editing, so nothing was saved."         }     } +    /// The kind the store holds this record under — the basis's, not the+    /// draft's, so a staged conversion does not rename a refusal's subject.+    private func storedKind(of recordID: UUID) -> RecordKind {+        recordBases[recordID]?.kind ?? recordDrafts[recordID]?.kind ?? .character+    }+     /// Commits metadata edit. Suppresses duplicate submissions.     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+    ///   False only inside `commitEditing()`, where a record 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 {@@ -1771,7 +1934,7 @@ public final class WorkDetailModel {                 //                 // Not a `load()`. That re-reads the record and reassigns every                 // draft — the title, the tags, the notes, the two statuses, the-                // verdict, the series draft and the staged character+                // verdict, the series draft and the staged record                 // operations — so a reader who had retitled the work and                 // written a verdict before picking a series another device had                 // just deleted would lose all of it. Req 2.4 is the one@@ -1800,7 +1963,7 @@ public final class WorkDetailModel {                 // so the picker's rows are wrong and the draft names a creator                 // no row backs. Exactly that is refreshed and exactly that is                 // dropped — never a `load()`, which would take the title, the-                // tags, the verdict and the staged character edits with it.+                // tags, the verdict and the staged record edits with it.                 if case .creatorMissing(_, let missingCreatorID) = conflict {                     await loadCreatorOptions()                     removeCredit(for: missingCreatorID)@@ -1829,8 +1992,8 @@ public final class WorkDetailModel {             // 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.+            // re-adopted the stored records and so silently threw away the+            // session's staged record 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/CharacterEditorView.swift Modified +106 / -54
diff --git a/Asterism/Asterism/Views/CharacterEditorView.swift b/Asterism/Asterism/Views/CharacterEditorView.swiftindex 0702d0d..c58932c 100644--- a/Asterism/Asterism/Views/CharacterEditorView.swift+++ b/Asterism/Asterism/Views/CharacterEditorView.swift@@ -2,25 +2,30 @@ import AsterismCore import ConstellationKit import SwiftUI -/// One character's editor (`character-extraction` Req 5.3), opened by tapping-/// its line in the Characters card — or by "Add a character", which mints a-/// draft and opens it for naming.+/// One record's editor (`character-extraction` Req 5.3, `place-extraction`+/// Reqs 3.2, 3.7), opened by tapping its line in the Characters or Places+/// card — or by "Add a character" / "Add a place", which mints a draft and+/// opens it for naming. ///-/// Everything the inline editor card drew, unchanged in what it writes: the-/// name and note fields, the aliases, the facts, the torn notice, and the two-/// structural actions. The card is gone from the page because a cast of ten-/// pills with one open editor under them was the section that could not fit.+/// One view for both kinds, parameterised by `kind` (design §Views): every word+/// it says comes from `RecordKindPresentation`, so the two collections cannot+/// come to describe themselves differently. `kind` is the kind the **draft** is+/// under, which a staged conversion has already flipped. ///-/// The edit session's draft state stays the model's (`characterDraft(for:)`,-/// `updateCharacterDraft`), so nothing here is written until the work's own+/// The edit session's draft state stays the model's (`recordDraft(for:)`,+/// `updateRecordDraft`), so nothing here is written until the work's own /// checkmark commits — the torn read-only gate and the conflict handling are /// the model's as before. struct CharacterEditorView: View {     let model: WorkDetailModel-    let characterID: UUID+    let recordID: UUID+    /// Which collection this editor is drawing. Passed in rather than read off+    /// the draft so the sheet keeps one identity for one presentation; a+    /// conversion dismisses it, because the line it opened from has gone.+    let kind: RecordKind     /// Where the reader is taken when a combine finishes: the target's editor,-    /// because the source's name just left the cast and the reader has to see-    /// where everything went.+    /// because the source's name just left the collection and the reader has to+    /// see where everything went.     let onCombined: (UUID) -> Void      @Environment(\.dismiss) private var dismiss@@ -28,41 +33,53 @@ struct CharacterEditorView: View {     /// behind the sheet: a dialog raised back there would be covered by this.     @State private var isCombining = false -    /// The stored character this draft belongs to, or nil for one created in-    /// this session — which has no stored row yet and so cannot be torn.-    private var character: WorkCharacterPresentation? {-        model.characters.first { $0.id == characterID }+    /// The stored record this draft belongs to, or nil for one created in this+    /// session — which has no stored row yet and so cannot be torn.+    private var record: WorkRecordPresentation? {+        model.characters.first { $0.id == recordID } ?? model.places.first { $0.id == recordID }     } -    private var draft: CharacterDraft? { model.characterDraft(for: characterID) }+    private var draft: RecordDraft? { model.recordDraft(for: recordID) } -    /// The per-character read-only gate, which is not the work's: a torn-    /// character is off limits while the rest of the cast is editable.+    /// The per-record read-only gate, which is not the work's: a torn record is+    /// off limits while the rest of the collection is editable.     private var editable: Bool {-        character.map { model.canEditCharacter(id: $0.id) } ?? !model.isReadOnly+        record.map { model.canEditRecord(id: $0.id) } ?? !model.isReadOnly     } +    /// The kind the store holds this record under. A conversion is staged, so+    /// this and the draft's kind disagree exactly while one is staged.+    private var storedKind: RecordKind { record?.kind ?? kind }++    /// Req 3.7: conversion is offered on a stored row the reader may edit. A+    /// torn record has no single content a conversion could carry (Q39), and a+    /// record created in this session is *created* under the other kind rather+    /// than converted (Q54), so neither is offered it.+    private var offersConversion: Bool { record != nil && editable }+     var body: some View {         let draft = self.draft+        // Once per body: `structuralActions` asks whether there are any and the+        // dialog lists them, and the model derives the answer from the staged+        // operations each time it is asked.+        let combineTargets = model.combineTargets(for: recordID)         return ConstellationEditorSheet(             title: title(draft),             identifier: "character-editor"         ) {             if let draft {                 Section {-                    TextField("Name", text: characterBinding(\.name))+                    TextField("Name", text: recordBinding(\.name))                         .disabled(!editable)                         .frame(minHeight: AsterismLayout.minHitTarget)                         .accessibilityIdentifier("work-detail-character-name-field")-                    TextField("Note", text: characterBinding(\.note), axis: .vertical)+                    TextField("Note", text: recordBinding(\.note), axis: .vertical)                         .disabled(!editable)                         .lineLimit(1...4)                         .accessibilityIdentifier("work-detail-character-note-field")                 } footer: {-                    if character?.isTorn == true {-                        Text(-                            "This character exists in differing copies — editing is off until "-                                + "you choose which one to keep.")+                    if record?.isTorn == true {+                        Text(RecordKindPresentation.tornNotice(storedKind))                             .foregroundStyle(AsterismColors.amberText)                             .accessibilityIdentifier("work-detail-character-torn-notice")                     }@@ -76,12 +93,12 @@ struct CharacterEditorView: View {                         aliases: draft.aliases,                         editable: editable,                         onRemove: { alias in-                            model.updateCharacterDraft(id: characterID) { draft in+                            model.updateRecordDraft(id: recordID) { draft in                                 draft.aliases.removeAll { $0 == alias }                             }                         },                         onAdd: { alias in-                            model.updateCharacterDraft(id: characterID) { draft in+                            model.updateRecordDraft(id: recordID) { draft in                                 guard !draft.aliases.contains(alias) else { return }                                 draft.aliases.append(alias)                             }@@ -102,7 +119,7 @@ struct CharacterEditorView: View {                 }                  Section {-                    structuralActions+                    structuralActions(combineTargets: combineTargets)                         .constellationListRow()                 }             }@@ -113,15 +130,15 @@ struct CharacterEditorView: View {         .confirmationDialog(             "Combine into", isPresented: $isCombining, titleVisibility: .visible         ) {-            ForEach(model.combineTargets(for: characterID)) { target in+            ForEach(combineTargets) { target in                 Button(target.name) {-                    model.combineCharacter(source: characterID, into: target.id)+                    model.combineRecord(source: recordID, into: target.id)                     isCombining = false                     onCombined(target.id)                 }-                // The cast lines carry the same character names as labels, so-                // the dialog's choices need their own identity to stay-                // addressable (the dialogButton precedent).+                // The collection's lines carry the same names as labels, so the+                // dialog's choices need their own identity to stay addressable+                // (the dialogButton precedent).                 .accessibilityIdentifier("work-detail-combine-target")             }             Button("Cancel", role: .cancel) { isCombining = false }@@ -130,17 +147,19 @@ struct CharacterEditorView: View {         }     } -    /// What the sheet is called. A draft with no name yet is the one the "Add a-    /// character" button just minted, and it is named by typing into the field-    /// below the title.-    private func title(_ draft: CharacterDraft?) -> String {-        guard let draft, !draft.name.isEmpty else { return "New character" }+    /// What the sheet is called. A draft with no name yet is the one the "Add+    /// a …" button just minted, and it is named by typing into the field below+    /// the title.+    private func title(_ draft: RecordDraft?) -> String {+        guard let draft, !draft.name.isEmpty else {+            return RecordKindPresentation.newRecordTitle(kind)+        }         return draft.name     }      /// One fact, with the way to drop it. The statement is the reader's own     /// sentence, so it wraps rather than truncates.-    private func factRow(_ fact: CharacterFact) -> some View {+    private func factRow(_ fact: RecordFact) -> some View {         HStack(alignment: .top, spacing: 8) {             Text(fact.statement)                 .font(.footnote)@@ -148,7 +167,7 @@ struct CharacterEditorView: View {                 .fixedSize(horizontal: false, vertical: true)                 .frame(maxWidth: .infinity, alignment: .leading)             Button {-                model.deleteFact(fact.identity, from: characterID)+                model.deleteFact(fact.identity, from: recordID)             } label: {                 Image(systemName: "minus.circle")                     .foregroundStyle(AsterismColors.secondaryText)@@ -164,25 +183,33 @@ struct CharacterEditorView: View {         }     } -    /// Combine and Delete, side by side on the shared bordered recipe: the one-    /// that keeps everything in violet, the one that ends the record in-    /// `secondaryText` with a trash glyph. Neither is system red — §11 gives the+    /// Combine, Convert and Delete on the shared bordered recipe: the two that+    /// keep everything in violet, and the one that ends the record in+    /// `secondaryText` with a trash glyph. None is system red — §11 gives the     /// palette no error hue.+    ///+    /// A `FlowLayout` rather than an `HStack` (Q46 put three actions where two+    /// were): three worded buttons do not fit one phone row at the accessibility+    /// text sizes, and wrapping is what the pill grids on this screen already+    /// do rather than clipping.     @ViewBuilder-    private var structuralActions: some View {-        HStack(spacing: 8) {-            if !model.combineTargets(for: characterID).isEmpty {+    private func structuralActions(+        combineTargets: [WorkRecordPresentation]+    ) -> some View {+        FlowLayout(spacing: 8) {+            if !combineTargets.isEmpty {                 ConstellationFooterButton(title: "Combine into…", systemImage: nil) {                     isCombining = true                 }                 .disabled(!editable)                 .accessibilityIdentifier("work-detail-character-combine")             }+            conversionAction             ConstellationFooterButton(-                title: "Delete character", systemImage: "trash",+                title: RecordKindPresentation.deleteTitle(kind), systemImage: "trash",                 tint: AsterismColors.secondaryText             ) {-                model.deleteCharacter(id: characterID)+                model.deleteRecord(id: recordID)                 dismiss()             }             .disabled(!editable)@@ -190,18 +217,43 @@ struct CharacterEditorView: View {         }     } -    private func characterBinding(-        _ keyPath: WritableKeyPath<CharacterDraft, String>+    /// Req 3.7's third structural action, between Combine and Delete (Q46).+    ///+    /// It writes nothing: the conversion is staged, the line moves to the other+    /// card at once, and the session's one Save applies it. Tapping it again+    /// takes it back, which is why the label changes rather than the button+    /// disappearing.+    @ViewBuilder+    private var conversionAction: some View {+        if offersConversion {+            ConstellationFooterButton(+                title: RecordKindPresentation.convertTitle(+                    from: storedKind, isConverted: model.isConverted(id: recordID)),+                systemImage: "arrow.left.arrow.right"+            ) {+                model.convertRecord(id: recordID)+                // The line has left this card. The sheet closes so the reader+                // sees where it landed — and so this editor is not left drawing+                // a collection the record is no longer in.+                dismiss()+            }+            .disabled(!editable)+            .accessibilityIdentifier("work-detail-character-convert")+        }+    }++    private func recordBinding(+        _ keyPath: WritableKeyPath<RecordDraft, String>     ) -> Binding<String> {         Binding(-            get: { model.characterDraft(for: characterID)?[keyPath: keyPath] ?? "" },+            get: { model.recordDraft(for: recordID)?[keyPath: keyPath] ?? "" },             set: { value in-                model.updateCharacterDraft(id: characterID) { $0[keyPath: keyPath] = value }+                model.updateRecordDraft(id: recordID) { $0[keyPath: keyPath] = value }             })     } } -/// Aliases in the character editor: each a removable chip, plus a field to add+/// Aliases in the record editor: each a removable chip, plus a field to add /// one. Its own view because the add field needs state of its own. struct CharacterAliasEditor: View {     let aliases: [String]
Asterism/Asterism/Views/CharacterReviewView.swift Modified +76 / -9
diff --git a/Asterism/Asterism/Views/CharacterReviewView.swift b/Asterism/Asterism/Views/CharacterReviewView.swiftindex d1ee30c..afd3889 100644--- a/Asterism/Asterism/Views/CharacterReviewView.swift+++ b/Asterism/Asterism/Views/CharacterReviewView.swift@@ -2,11 +2,14 @@ import AsterismCore import ConstellationKit import SwiftUI -/// The review list (Reqs 2.1, 2.2, 2.5, 2.7).+/// The review list (`character-extraction` Reqs 2.1, 2.2, 2.5, 2.7;+/// `place-extraction` Reqs 2.1–2.3). ///-/// 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.+/// One section per candidate or bundle, of either kind, each with its own Keep+/// and Skip and — where the row is the reader's to re-file — a kind control+/// above them. The list is a snapshot taken when the sheet opened, so nothing+/// moves under the reader while they decide, and a reclassification redraws its+/// own row in place rather than moving it. struct CharacterReviewView: View {     @Bindable var model: CharacterReviewModel     let onDone: () -> Void@@ -22,7 +25,7 @@ struct CharacterReviewView: View {                     reviewContent                 }             }-            .navigationTitle("Suggested characters")+            .navigationTitle("Suggested characters and places")             .toolbar {                 // Every row commits on its own, so this button commits nothing —                 // it leaves the list, and undecided rows stay for a later visit@@ -69,22 +72,62 @@ struct CharacterReviewView: View {             }              ForEach(model.rows) { row in-                Section(header: ConstellationSectionHeader(row.name, accent: .cyan)) {+                Section(header: header(row)) {                     rowContent(row)                 }             }         }     } +    /// Req 2.1: every row says which collection it would join. The name is the+    /// header the sheet has always drawn; the kind is a caption under it, so a+    /// place row is not a character row with a different word in it.+    private func header(_ row: CharacterReviewRow) -> some View {+        VStack(alignment: .leading, spacing: 2) {+            ConstellationSectionHeader(row.name, accent: .cyan)+            Text(RecordKindPresentation.name(row.kind))+                .font(.caption)+                .foregroundStyle(AsterismColors.secondaryText)+                // A Form header uppercases its content; the caption is a word,+                // not a label.+                .textCase(nil)+                .accessibilityIdentifier("character-review-kind-caption-\(row.id)")+        }+    }+     @ViewBuilder     private func rowContent(_ row: CharacterReviewRow) -> some View {         if row.isBundle {-            Text("Adds to a character you already have.")+            Text("Adds to a \(RecordKindPresentation.noun(row.kind)) you already have.")                 .font(.caption)                 .foregroundStyle(.secondary)                 .accessibilityIdentifier("character-review-bundle-note")         } +        // Q41: the model files places as characters often enough that the+        // reader is told when the other collection already answers to this+        // name. It commits nothing and changes no matching — it is the reason+        // the kind control below is worth looking at.+        //+        // Secondary text, not amber: the guide reserves amber for a state the+        // reader has to clear (§Palette, §Never), and this is presentation+        // beside two other resting notes that say what the row is.+        if let hint = row.crossKindHint {+            Text(hint)+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("character-review-hint-\(row.id)")+        }++        // Req 2.1/Q38: its label says one kind, but skipping it decides both+        // (Q23), and the reader must see the wider action before they take it.+        if row.isDualKind {+            Text("Suggested as both a character and a place")+                .font(.caption)+                .foregroundStyle(.secondary)+                .accessibilityIdentifier("character-review-dual-\(row.id)")+        }+         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@@ -113,10 +156,10 @@ struct CharacterReviewView: View {         }          if row.proposedFacts.isEmpty, row.aliases.isEmpty {-            Text("A name, with nothing said about them yet.")+            Text(RecordKindPresentation.nameOnlyNote(row.kind))                 .font(.caption)                 .foregroundStyle(.secondary)-                .accessibilityIdentifier("character-review-name-only")+                .accessibilityIdentifier("character-review-name-only-\(row.id)")         }          // Req 2.1: a bundle is shown *beside* what the character already holds,@@ -132,12 +175,36 @@ struct CharacterReviewView: View {             .accessibilityIdentifier("character-review-existing-\(row.id)")         } +        // Req 2.2: the reader's one-tap correction of the model's kind, on the+        // rows that are theirs to re-file. `ConstellationSegmentedControl`+        // rather than a `Picker`: §7 defines the segmented capsule once, and it+        // is the shape that stacks instead of clipping at the accessibility+        // sizes.+        if row.canReclassify {+            ConstellationSegmentedControl(+                values: RecordKind.allCases,+                selection: Binding(+                    get: { row.kind },+                    set: { model.reclassify(row.id, to: $0) }),+                containerLabel: "Record kind",+                title: RecordKindPresentation.name,+                identifier: {+                    RecordKindPresentation.reviewControlIdentifier(rowID: row.id, kind: $0)+                })+                .accessibilityIdentifier("character-review-kind-\(row.id)")+                .disabled(model.isSubmitting)+        }+         HStack(spacing: 12) {             Button("Keep") {                 Task { await model.accept(row.id) }             }             .buttonStyle(.constellationPrimary)             .accessibilityIdentifier("character-review-keep-\(row.id)")+            // Req 2.2: a reclassified row whose facts all deduped away has+            // nothing to keep. It stays, and the skip beside it still decides+            // it.+            .disabled(!row.canAccept)              Button("Skip") {                 Task { await model.skip(row.id) }
Asterism/Asterism/Views/DuplicateResolutionView.swift Modified +8 / -1
diff --git a/Asterism/Asterism/Views/DuplicateResolutionView.swift b/Asterism/Asterism/Views/DuplicateResolutionView.swiftindex 12fe955..c15e8b1 100644--- a/Asterism/Asterism/Views/DuplicateResolutionView.swift+++ b/Asterism/Asterism/Views/DuplicateResolutionView.swift@@ -92,6 +92,13 @@ struct DuplicateResolutionView: View {                 ForEach(model.characterVariants) { variant in                     variantRow(id: variant.id) { characterVariantContent(variant) }                 }+                // `place-extraction` Req 5.3: a torn place is chosen between+                // exactly as a torn character is — same payload, same rows;+                // what differs is the table the commit writes to, which the set+                // key already says.+                ForEach(model.placeVariants) { 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@@ -174,7 +181,7 @@ struct DuplicateResolutionView: View {     /// a divergent character can hold dozens, and the choice is between copies,     /// not between facts.     @ViewBuilder-    private func characterVariantContent(_ variant: CharacterVariantChoice) -> some View {+    private func characterVariantContent(_ variant: RecordVariantChoice) -> some View {         VStack(alignment: .leading, spacing: 4) {             Text(variant.name.isEmpty ? "(no name)" : variant.name)                 .font(.body)
Asterism/Asterism/Views/EntryDetailView.swift Modified +26 / -7
diff --git a/Asterism/Asterism/Views/EntryDetailView.swift b/Asterism/Asterism/Views/EntryDetailView.swiftindex 2a1b041..b3f4d9a 100644--- a/Asterism/Asterism/Views/EntryDetailView.swift+++ b/Asterism/Asterism/Views/EntryDetailView.swift@@ -175,6 +175,7 @@ struct EntryDetailView: View {             }              citingCharactersSection+            citingPlacesSection              // Q49: last, and collapsed. The capture's site and the             // title-recovery actions — neither is something a reader needs@@ -367,20 +368,38 @@ struct EntryDetailView: View {     /// are edited.     @ViewBuilder     private var citingCharactersSection: some View {-        if !model.citingCharacters.isEmpty {+        citingRecordsSection(model.citingCharacters, kind: .character)+    }++    /// `place-extraction` Req 4.4: the same list for places, directly after the+    /// characters one and separate from it (Q16) — a merged list would need a+    /// kind label on every row for no gain. Absent rather than empty, on the+    /// characters section's terms.+    @ViewBuilder+    private var citingPlacesSection: some View {+        citingRecordsSection(model.citingPlaces, kind: .place)+    }++    @ViewBuilder+    private func citingRecordsSection(+        _ records: [EntryCitingRecord], kind: RecordKind+    ) -> some View {+        if !records.isEmpty {             Section {-                ForEach(model.citingCharacters) { character in-                    LabeledContent(character.name) {-                        Text(Pluralisation.count(character.factCount, "fact", "facts"))+                ForEach(records) { record in+                    LabeledContent(record.name) {+                        Text(Pluralisation.count(record.factCount, "fact", "facts"))                             .font(.caption)                             .foregroundStyle(.secondary)                     }-                    .accessibilityIdentifier("entry-detail-citing-character")+                    .accessibilityIdentifier(+                        "entry-detail-citing-\(RecordKindPresentation.noun(kind))")                     .accessibilityLabel(-                        "\(character.name), \(Pluralisation.count(character.factCount, "fact", "facts")) citing this note")+                        "\(record.name), \(Pluralisation.count(record.factCount, "fact", "facts")) citing this note")                 }             } header: {-                ConstellationSectionHeader("Characters", accent: .violet)+                ConstellationSectionHeader(+                    RecordKindPresentation.collection(kind), accent: .violet)             }         }     }
Asterism/Asterism/Views/RecentView.swift Modified +7 / -6
diff --git a/Asterism/Asterism/Views/RecentView.swift b/Asterism/Asterism/Views/RecentView.swiftindex 4c2bf73..a0bd8b9 100644--- a/Asterism/Asterism/Views/RecentView.swift+++ b/Asterism/Asterism/Views/RecentView.swift@@ -748,12 +748,13 @@ 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. "+        // `character-extraction` Req 6.5, `place-extraction` Req 5.3: a torn+        // character or place reaches this filter like any other non-Entry set,+        // and the work-shaped sentences below would call it a work. Both are+        // resolved in the same sheet, so only the noun changes.+        if item.recordType == .character || item.recordType == .place {+            let noun = item.recordType == .character ? "character" : "place"+            return "A \(noun) arrived more than once and the copies differ. "                 + "Choose which one to keep."         }         switch item.route {
Asterism/Asterism/Views/RecordKindPresentation.swift Added +130 / -0
diff --git a/Asterism/Asterism/Views/RecordKindPresentation.swift b/Asterism/Asterism/Views/RecordKindPresentation.swiftnew file mode 100644index 0000000..43d225b--- /dev/null+++ b/Asterism/Asterism/Views/RecordKindPresentation.swift@@ -0,0 +1,130 @@+import AsterismCore+import Foundation++/// The one place a `RecordKind` becomes words (design §Review, §Edit session).+///+/// Beside `WorkStatusPresentation.swift`, and for its reason: the review sheet's+/// section caption and kind control, the work page's two collections, and the+/// editor's own copy all say the same two nouns, and a table each is a chance+/// for them to disagree in a way only a reader would notice.+///+/// `nonisolated` throughout: the entries are pure, and value-type helpers read+/// them from outside the actor under this target's MainActor-by-default+/// isolation.+nonisolated enum RecordKindPresentation {++    /// One record of this kind, as the reader is told it is called — the review+    /// sheet's section caption and the kind control's segment label.+    static func name(_ kind: RecordKind) -> String {+        switch kind {+        case .character: "Character"+        case .place: "Place"+        }+    }++    /// The collection, for a section header or a card caption.+    static func collection(_ kind: RecordKind) -> String {+        switch kind {+        case .character: "Characters"+        case .place: "Places"+        }+    }++    /// The lower-case noun, for a sentence that names one — "a place you already+    /// have".+    static func noun(_ kind: RecordKind) -> String {+        switch kind {+        case .character: "character"+        case .place: "place"+        }+    }++    /// The lower-case plural, for a counted phrase — "two places are the same+    /// thing". `collection(_:)`'s word, cased for the middle of a sentence.+    static func plural(_ kind: RecordKind) -> String { collection(kind).lowercased() }++    /// The review sheet's line for a row that proposes a name and nothing else.+    /// Per kind, because "them" is a thing to say about a character and not+    /// about a place.+    static func nameOnlyNote(_ kind: RecordKind) -> String {+        "A name, with nothing said about this \(noun(kind)) yet."+    }++    /// How a sentence refers back to one: a character is a "them", a place an+    /// "it". The only grammatical difference between the two kinds' copy, and+    /// the reason the sentences below are built here rather than branched on+    /// `kind` at each call site.+    static func objectPronoun(_ kind: RecordKind) -> String {+        switch kind {+        case .character: "them"+        case .place: "it"+        }+    }++    /// The review sheet's Q41 hint: the other kind already answers to this name.+    static func existingNameHint(_ kind: RecordKind) -> String {+        "You already have a \(noun(kind)) with this name"+    }++    /// Req 2.8's re-route disclosure — the row resolved onto a record the+    /// library already holds, so nothing was written.+    static func reRouteDisclosure(_ kind: RecordKind) -> String {+        "This turned out to belong to a \(noun(kind)) you already have, "+            + "so nothing was saved. It is shown against \(objectPronoun(kind)) now."+    }++    /// Req 2.8's torn disclosure, for a refusal that names the record.+    static func tornRecordDisclosure(_ kind: RecordKind) -> String {+        "That \(noun(kind)) exists in differing copies, so nothing can be added to "+            + "\(objectPronoun(kind)) yet. Open Check Library to choose which copy to keep."+    }++    /// The review sheet's kind-control segment identifier, spelled here beside+    /// the label so the journeys and the view read one table.+    static func reviewControlIdentifier(rowID: String, kind: RecordKind) -> String {+        "character-review-kind-\(rowID)-\(kind.rawValue)"+    }++    // MARK: - The editor's copy (`place-extraction` Req 3.7, design §Edit session)++    /// The editor sheet's title for a draft with no name yet — the one "Add a+    /// …" just minted, named by typing into the field below the title.+    static func newRecordTitle(_ kind: RecordKind) -> String {+        switch kind {+        case .character: "New character"+        case .place: "New place"+        }+    }++    /// The editor's delete action.+    static func deleteTitle(_ kind: RecordKind) -> String { "Delete \(noun(kind))" }++    /// The card's footer button on the work editor.+    static func addTitle(_ kind: RecordKind) -> String { "Add a \(noun(kind))" }++    /// The other kind — what a conversion goes to. `RecordKind.other`'s+    /// spelling, kept here so the copy below reads in one vocabulary.+    static func other(_ kind: RecordKind) -> RecordKind { kind.other }++    /// Req 3.7's conversion action, between Combine and Delete. `isConverted`+    /// is the take-it-back wording: the reader is looking at a line that has+    /// already moved cards, so "Make this a place" again would read as a second+    /// conversion.+    static func convertTitle(from kind: RecordKind, isConverted: Bool) -> String {+        isConverted+            ? "Make this a \(noun(kind)) again"+            : "Make this a \(noun(other(kind)))"+    }++    /// The torn notice inside the editor.+    static func tornNotice(_ kind: RecordKind) -> String {+        "This \(noun(kind)) exists in differing copies — editing is off until "+            + "you choose which one to keep."+    }++    /// The prefix every one of a kind's work-page identifiers is built from:+    /// `work-detail-character…` and `work-detail-place…`.+    static func workDetailIdentifier(_ kind: RecordKind, _ suffix: String = "") -> String {+        "work-detail-\(noun(kind))\(suffix)"+    }+}
Asterism/Asterism/Views/WorkDetailView.swift Modified +227 / -111
diff --git a/Asterism/Asterism/Views/WorkDetailView.swift b/Asterism/Asterism/Views/WorkDetailView.swiftindex 5c46ccc..61a4d1b 100644--- a/Asterism/Asterism/Views/WorkDetailView.swift+++ b/Asterism/Asterism/Views/WorkDetailView.swift@@ -1,4 +1,6 @@ import AsterismCore+// For `ProposalKey`, which names a held row for the coordinator's decisions.+import AsterismIntelligence import ConstellationKit import SwiftUI @@ -68,10 +70,18 @@ struct WorkDetailView: View {     /// Which character's detail card is open under the cast pills; nil folds     /// every quote away.     @State private var expandedCharacterID: UUID?+    /// The same, for the Places section (`place-extraction` Req 4.1). Its own+    /// selection rather than one shared with the cast: the two sections sit one+    /// above the other, and opening a place would otherwise fold a character's+    /// card the reader was still reading.+    @State private var expandedPlaceID: UUID?     /// Which character's editor **sheet** is open in the edit session — the     /// same one-at-a-time selection the inline editor card had, per mode, so     /// the two selections do not fight.     @State private var expandedEditCharacterID: UUID?+    /// The same for the Places card. Two bindings rather than one, so a sheet+    /// raised from one card cannot be handed a record id from the other.+    @State private var expandedEditPlaceID: UUID?     /// Which order the spine is read in. `@State` and not stored (Q3): Newest is     /// the reading default and every open of the screen starts there.     @State private var sortOrder: WorkDetailModel.ChapterSortOrder = .newest@@ -201,12 +211,13 @@ struct WorkDetailView: View {     /// what it is part of and related to, who is in it, and what can be done to     /// the record itself.     ///-    /// Seven sections, three of which carry a `ConstellationSectionHeader` and-    /// four of which are captioned cards naming themselves — the shape the+    /// Eight sections, three of which carry a `ConstellationSectionHeader` and+    /// five of which are captioned cards naming themselves — the shape the     /// design canvas settled on (`specs/work-creators/decision_log.md`, the-    /// edit-screen decision). The Work URL and the URL-identity controls have-    /// no sections of their own any more: the field is a row of the Work card-    /// and the two controls are the first rows of Manage.+    /// edit-screen decision), with Places joining the collections at+    /// `place-extraction` Req 3.2. The Work URL and the URL-identity controls+    /// have no sections of their own any more: the field is a row of the Work+    /// card and the two controls are the first rows of Manage.     ///     /// Named rather than written inline in `workContent` because the two mode     /// branches together defeated the type checker once the series and link@@ -219,6 +230,7 @@ struct WorkDetailView: View {         editCreditsSection         editSeriesSection         editCharactersSection+        editPlacesSection         manageSection     } @@ -281,6 +293,7 @@ struct WorkDetailView: View {         seriesSection         openLastNotedSection         charactersSection+        placesSection         relatedWorksSection         chapterSection     }@@ -314,7 +327,7 @@ struct WorkDetailView: View {     private func workList(_ work: WorkSnapshot) -> some View {         List {             if model.isReadOnly { duplicateReviewSection }-            tornCharactersSection+            tornRecordsSection             proposalsIndicatorSection              if model.isEditing {@@ -369,7 +382,9 @@ struct WorkDetailView: View {                 editingCredit: $editingCredit))         .modifier(             WorkCharacterPresentations(-                model: model, editingCharacter: $expandedEditCharacterID))+                model: model,+                editingCharacter: $expandedEditCharacterID,+                editingPlace: $expandedEditPlaceID))         .markdownExportShare(             model: exportModel, sheetIdentifier: "work-detail-export-share-sheet")         // Req 7.1's two dispositions plus cancel. `presenting:` hands the@@ -1509,6 +1524,7 @@ struct WorkDetailView: View {                     // Each session starts folded; last session's open editor is                     // not this session's intent.                     expandedEditCharacterID = nil+                    expandedEditPlaceID = nil                     model.beginEditing()                     // Req 7.1's vocabulary, for the chips on each link card.                     // Read on entering the editor rather than on every open of@@ -1529,21 +1545,26 @@ struct WorkDetailView: View {         }     } -    // MARK: - Characters (`character-extraction` Reqs 2.1, 5.1–5.3, 1.11)+    // MARK: - Characters and places (`character-extraction` Reqs 2.1, 5.1–5.3,+    // 1.11; `place-extraction` Reqs 4.1–4.3) -    /// Req 6.5: torn characters are disclosed the way torn works are — in the+    /// Req 6.5: torn records 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.+    /// A torn record is otherwise only visible as a label deep in its own+    /// section, which is not a disclosure the reader will meet.+    ///+    /// One card for both collections (`place-extraction` Req 5.3): the sentence+    /// names whichever kinds are torn, because two amber cards stacked would+    /// read as two problems where the reader has one route.     @ViewBuilder-    private var tornCharactersSection: some View {-        let torn = model.characters.filter(\.isTorn)+    private var tornRecordsSection: some View {+        let torn = model.characters.filter(\.isTorn) + model.places.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.")+                        : "\(torn.count) \(Self.tornNoun(torn)) arrived more than once and their copies differ.")                         .font(.callout)                         .foregroundStyle(AsterismColors.primaryText)                         .accessibilityIdentifier("work-detail-torn-characters-notice")@@ -1564,6 +1585,14 @@ struct WorkDetailView: View {         }     } +    /// What to call a mixed set of torn records: the collection where they are+    /// all of one kind, and the neutral word where they are not.+    private static func tornNoun(_ torn: [WorkRecordPresentation]) -> String {+        let kinds = Set(torn.map(\.kind))+        guard let only = kinds.first, kinds.count == 1 else { return "records" }+        return RecordKindPresentation.collection(only).lowercased()+    }+     /// 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.@@ -1582,8 +1611,14 @@ struct WorkDetailView: View {            extraction.hasProposals(for: work.id) {             Section {                 VStack(alignment: .leading, spacing: 8) {+                    // `place-extraction` Req 2.1: one indicator for both kinds,+                    // counting every held row. The noun drops "character" —+                    // the sheet it opens decides places too, and a count+                    // labelled "character suggestions" that opened a list with+                    // places in it would be the screen lying about its own+                    // button.                     Label(-                        "\(Pluralisation.count(extraction.held(for: work.id).count, "character suggestion", "character suggestions")) from your notes",+                        "\(Pluralisation.count(extraction.held(for: work.id).count, "suggestion", "suggestions")) from your notes",                         systemImage: "sparkles")                         .font(.callout)                         .foregroundStyle(AsterismColors.primaryText)@@ -1614,26 +1649,58 @@ struct WorkDetailView: View {     private var charactersSection: some View {         if !model.characters.isEmpty || offersManualPass {             Section {-                if !model.characters.isEmpty {-                    // The cast as a wrapping pill grid — the quotes stay folded-                    // until a name is asked for. `.plain` on every pill, not the-                    // default style: sibling buttons in one List row bleed their-                    // hit areas into each other otherwise.-                    FlowLayout(spacing: 8) {-                        ForEach(model.characters) { character in-                            characterPill(character)-                        }-                    }-                    .frame(maxWidth: .infinity, alignment: .leading)-                    .constellationListRow()-                }-                if let expanded = model.characters.first(where: { $0.id == expandedCharacterID }) {-                    characterDetailCard(expanded)-                }+                recordCollection(model.characters, expanded: $expandedCharacterID)                 manualPassRow             } header: {-                ConstellationSectionHeader("Characters", accent: .violet)+                ConstellationSectionHeader(+                    RecordKindPresentation.collection(.character), accent: .violet)+            }+        }+    }++    /// `place-extraction` Req 4.1: the same composition as the cast, directly+    /// after it. A work with no places shows no section at all — the reader who+    /// has kept none is not shown an empty box telling them so, and the shared+    /// proposals indicator above is the only place-related element such a work+    /// carries.+    ///+    /// The manual-pass trigger stays in the Characters section: one pass+    /// produces both kinds, so a second trigger here would be a second button+    /// for one action.+    @ViewBuilder+    private var placesSection: some View {+        if !model.places.isEmpty {+            Section {+                recordCollection(model.places, expanded: $expandedPlaceID)+            } header: {+                ConstellationSectionHeader(+                    RecordKindPresentation.collection(.place), accent: .violet)+            }+        }+    }++    /// One collection's pills and the one open detail card under them, shared+    /// by both sections and told apart only by the kind each record carries+    /// (design §Views: a place surface mirrors the character one).+    @ViewBuilder+    private func recordCollection(+        _ records: [WorkRecordPresentation], expanded: Binding<UUID?>+    ) -> some View {+        if !records.isEmpty {+            // The collection as a wrapping pill grid — the quotes stay folded+            // until a name is asked for. `.plain` on every pill, not the+            // default style: sibling buttons in one List row bleed their hit+            // areas into each other otherwise.+            FlowLayout(spacing: 8) {+                ForEach(records) { record in+                    recordPill(record, expanded: expanded)+                }             }+            .frame(maxWidth: .infinity, alignment: .leading)+            .constellationListRow()+        }+        if let open = records.first(where: { $0.id == expanded.wrappedValue }) {+            recordDetailCard(open)         }     } @@ -1697,25 +1764,27 @@ struct WorkDetailView: View {             "\(link.linkType), \(link.displayTitle)")     } -    /// One name in the cast. The identifier sits on the button — a leaf-    /// element, so nothing inside is masked and the pill count is the cast-    /// count.-    private func characterPill(_ character: WorkCharacterPresentation) -> some View {-        let isExpanded = expandedCharacterID == character.id+    /// One name in a collection. The identifier sits on the button — a leaf+    /// element, so nothing inside is masked and the pill count is the+    /// collection's count.+    private func recordPill(+        _ record: WorkRecordPresentation, expanded: Binding<UUID?>+    ) -> some View {+        let isExpanded = expanded.wrappedValue == record.id         return Button {             withAnimation(.snappy) {-                expandedCharacterID = isExpanded ? nil : character.id+                expanded.wrappedValue = isExpanded ? nil : record.id             }         } label: {             HStack(spacing: 5) {-                Text(character.name)-                if character.isTorn {+                Text(record.name)+                if record.isTorn {                     // Req 6.5: disclosed where the reader meets it; the full                     // sentence is on the detail card.                     Image(systemName: "exclamationmark.circle")                         .foregroundStyle(AsterismColors.amberText)                 }-                Text("\(character.facts.count)")+                Text("\(record.facts.count)")                     .foregroundStyle(AsterismColors.secondaryText)                 Image(systemName: "chevron.down")                     .font(.caption2)@@ -1726,38 +1795,41 @@ struct WorkDetailView: View {             .constellationPill(isExpanded ? .selectedTypeTag : .typeTag)         }         .buttonStyle(.plain)-        .accessibilityIdentifier("work-detail-character")-        .accessibilityLabel("\(character.name), \(character.facts.count) facts")+        .accessibilityIdentifier(RecordKindPresentation.workDetailIdentifier(record.kind))+        .accessibilityLabel("\(record.name), \(record.facts.count) facts")     } -    /// The one expanded character: the name and its aliases, the note, then the+    /// The one expanded record: the name and its aliases, the note, then the     /// facts on the same gutter the spine uses — everything the old always-open     /// row showed, on request and laid out to be read.-    private func characterDetailCard(_ character: WorkCharacterPresentation) -> some View {-        VStack(alignment: .leading, spacing: 10) {+    private func recordDetailCard(_ record: WorkRecordPresentation) -> some View {+        let kind = record.kind+        return VStack(alignment: .leading, spacing: 10) {             // Q14: the aliases wrap beside the name as `.count` chips, the same             // recipe the editor's alias chips already use. "Also Nightjar, Jay"             // was a sentence about match keys; these are the keys.             FlowLayout(spacing: 8) {-                Text(character.name)+                Text(record.name)                     .font(AsterismTypography.serifHeading)-                ForEach(character.aliases, id: \.self) { alias in+                ForEach(record.aliases, id: \.self) { alias in                     Text(alias)                         .constellationPill(.count)-                        .accessibilityIdentifier("work-detail-character-alias-chip")+                        .accessibilityIdentifier(+                            RecordKindPresentation.workDetailIdentifier(kind, "-alias-chip"))                 }-                if character.isTorn {+                if record.isTorn {                     // Req 6.5: resolved where every torn record is.                     Label("Differing copies", systemImage: "exclamationmark.circle")                         .font(.caption)                         .foregroundStyle(AsterismColors.amberText)-                        .accessibilityIdentifier("work-detail-character-torn")+                        .accessibilityIdentifier(+                            RecordKindPresentation.workDetailIdentifier(kind, "-torn"))                 }             }             .frame(maxWidth: .infinity, alignment: .leading) -            if !character.note.isEmpty {-                Text(character.note)+            if !record.note.isEmpty {+                Text(record.note)                     .font(.subheadline)                     .foregroundStyle(AsterismColors.noteText)                     .lineSpacing(4)@@ -1765,12 +1837,12 @@ struct WorkDetailView: View {                     .frame(maxWidth: .infinity, alignment: .leading)             } -            if !character.facts.isEmpty {+            if !record.facts.isEmpty {                 Rectangle()                     .fill(AsterismColors.cardBorder)                     .frame(height: 1)-                ForEach(character.facts) { fact in-                    characterFactRow(fact)+                ForEach(record.facts) { fact in+                    recordFactRow(fact, kind: kind)                 }             }         }@@ -1787,9 +1859,9 @@ struct WorkDetailView: View {     /// The citation moves into the gutter (Q10): where the cited note has a     /// chapter key it is the number the spine draws, so the fact says which     /// chapter it came from without a caption to read (Q12).-    private func characterFactRow(_ fact: WorkCharacterFactRow) -> some View {+    private func recordFactRow(_ fact: WorkRecordFactRow, kind: RecordKind) -> some View {         HStack(alignment: .top, spacing: 8) {-            citationGutter(fact)+            citationGutter(fact, kind: kind)             VStack(alignment: .leading, spacing: 2) {                 Text(fact.statement)                     .font(.subheadline)@@ -1803,7 +1875,9 @@ struct WorkDetailView: View {                     // others carry no identifier rather than an empty one.                     if fact.isDangling {                         captionText-                            .accessibilityIdentifier("work-detail-character-citation-dangling")+                            .accessibilityIdentifier(+                                RecordKindPresentation.workDetailIdentifier(+                                    kind, "-citation-dangling"))                     } else {                         captionText                     }@@ -1817,13 +1891,13 @@ struct WorkDetailView: View {         // (`docs/agent-notes/testing.md`). `.contain` keeps the row findable as         // a group while its children keep their own identifiers.         .accessibilityElement(children: .contain)-        .accessibilityIdentifier("work-detail-character-fact")+        .accessibilityIdentifier(RecordKindPresentation.workDetailIdentifier(kind, "-fact"))     }      /// The gutter beside a fact: a link to the note it came from, or a dash     /// where there is no note to open.     @ViewBuilder-    private func citationGutter(_ fact: WorkCharacterFactRow) -> some View {+    private func citationGutter(_ fact: WorkRecordFactRow, kind: RecordKind) -> some View {         if let entryID = fact.citedEntryID {             if let onSelectEntry {                 Button { onSelectEntry(entryID) } label: {@@ -1838,7 +1912,8 @@ struct WorkDetailView: View {                         .contentShape(Rectangle())                 }                 .buttonStyle(.plain)-                .accessibilityIdentifier("work-detail-character-citation")+                .accessibilityIdentifier(+                    RecordKindPresentation.workDetailIdentifier(kind, "-citation"))                 .accessibilityLabel("Open \(citationName(fact))")             } else {                 // A host with no entry route — the Merge screen's embedded copy,@@ -1864,7 +1939,7 @@ struct WorkDetailView: View {     /// number it — the gutter has to read as a link either way, and a bare "Ch."     /// would read as a number that failed to render.     @ViewBuilder-    private func citationGlyph(_ fact: WorkCharacterFactRow) -> some View {+    private func citationGlyph(_ fact: WorkRecordFactRow) -> some View {         if let label = fact.citedChapterKey?.label {             Text(label)                 .font(AsterismTypography.mono.weight(.semibold))@@ -1879,7 +1954,7 @@ struct WorkDetailView: View {      /// Q12: a live citation with a key says everything in its gutter. Without a     /// key the arrow alone would not, so the note's title or date stays.-    private func citationCaption(_ fact: WorkCharacterFactRow) -> String? {+    private func citationCaption(_ fact: WorkRecordFactRow) -> String? {         if fact.citedEntryID != nil {             return fact.citedChapterKey == nil ? citationName(fact) : nil         }@@ -1892,7 +1967,7 @@ struct WorkDetailView: View {     /// its own title where that says more than the work's name — and the capture     /// date otherwise, because on the work's own page a link labelled with the     /// work's title identifies nothing.-    private func citationName(_ fact: WorkCharacterFactRow) -> String {+    private func citationName(_ fact: WorkRecordFactRow) -> String {         fact.citationTitle ?? citationDateLabel(fact)     } @@ -1905,7 +1980,7 @@ struct WorkDetailView: View {     /// The citation's label of last resort: the note's capture date. Always the     /// abbreviated date — unlike the spine's "Today", it is read in a sentence     /// ("Noted 12 Aug"), where the relative form would not scan.-    private func citationDateLabel(_ fact: WorkCharacterFactRow) -> String {+    private func citationDateLabel(_ fact: WorkRecordFactRow) -> String {         guard let date = fact.citationDate else { return "this note" }         return "Noted \(date.formatted(date: .abbreviated, time: .omitted))"     }@@ -1929,7 +2004,11 @@ struct WorkDetailView: View {                 Button {                     Task { await runManualPass(workID: work.id) }                 } label: {-                    Label("Look for characters", systemImage: "sparkles")+                    // `place-extraction` Req 2.1: one pass, both kinds. The+                    // label says so, because a trigger reading "Look for+                    // characters" that returns places is the same lie the+                    // indicator's noun was.+                    Label("Look for characters and places", systemImage: "sparkles")                         .frame(maxWidth: .infinity, minHeight: AsterismLayout.minHitTarget)                 }                 .buttonStyle(.constellationSecondary)@@ -1941,7 +2020,7 @@ struct WorkDetailView: View {                             .controlSize(.small)                             .padding(.trailing, 18)                             .accessibilityIdentifier("work-detail-extract-busy")-                            .accessibilityLabel("Looking for characters")+                            .accessibilityLabel("Looking for characters and places")                     }                 } @@ -1985,57 +2064,71 @@ struct WorkDetailView: View {     /// selection is the same `expandedEditCharacterID` it always was; it now     /// says which sheet is up rather than which card is unfolded.     private var editCharactersSection: some View {-        Section {+        editRecordsSection(of: .character, opening: $expandedEditCharacterID)+    }++    /// `place-extraction` Req 3.2's Places card, directly after Characters and+    /// on its terms: the same compact lines, the same footer button, the same+    /// sheet. Fifth captioned card, fourth collection.+    private var editPlacesSection: some View {+        editRecordsSection(of: .place, opening: $expandedEditPlaceID)+    }++    /// One collection's card. The lines come from the model's `editLines(of:)`,+    /// which reads the **drafts**, so a staged conversion moves a line from one+    /// card to the other the moment the reader taps.+    private func editRecordsSection(+        of kind: RecordKind, opening editing: Binding<UUID?>+    ) -> some View {+        let lines = model.editLines(of: kind)+        return Section {             VStack(alignment: .leading, spacing: 12) {-                if !model.characters.isEmpty || !newCharacterIDs.isEmpty {+                if !lines.isEmpty {                     VStack(alignment: .leading, spacing: 6) {-                        ForEach(model.characters) { character in-                            if let draft = model.characterDraft(for: character.id) {-                                editCharacterLine(-                                    id: character.id, draft: draft, isTorn: character.isTorn)-                            }-                        }-                        ForEach(newCharacterIDs, id: \.self) { id in-                            if let draft = model.characterDraft(for: id) {-                                editCharacterLine(id: id, draft: draft, isTorn: false)-                            }+                        ForEach(lines) { line in+                            editRecordLine(line, kind: kind, opening: editing)                         }                     }                 } -                ConstellationFooterButton(title: "Add a character") {+                ConstellationFooterButton(title: RecordKindPresentation.addTitle(kind)) {                     // The new draft opens for naming; a line labelled "New                     // character" with nothing open would be a dead end.-                    expandedEditCharacterID = model.addCharacter(named: "")+                    editing.wrappedValue = model.addRecord(kind, named: "")                 }                 .disabled(model.isReadOnly)-                .accessibilityIdentifier("work-detail-add-character")+                .accessibilityIdentifier("work-detail-add-\(RecordKindPresentation.noun(kind))")             }-            .constellationCaptionedCard("Characters")+            .constellationCaptionedCard(RecordKindPresentation.collection(kind))         }     } -    /// One name in the edit session's cast, with what its draft holds. Labelled-    /// from the draft, so a rename shows on the line as it is typed.-    private func editCharacterLine(id: UUID, draft: CharacterDraft, isTorn: Bool) -> some View {-        let name = draft.name.isEmpty ? "New character" : draft.name-        let counts = Self.characterCounts(draft)-        return ConstellationLineRow(name: name, detail: counts, showsAttention: isTorn) {-            expandedEditCharacterID = id+    /// One name in the edit session's collection, with what its draft holds.+    /// Labelled from the draft, so a rename shows on the line as it is typed.+    private func editRecordLine(+        _ line: WorkDetailModel.RecordEditLine, kind: RecordKind, opening editing: Binding<UUID?>+    ) -> some View {+        let name = line.draft.name.isEmpty+            ? RecordKindPresentation.newRecordTitle(kind) : line.draft.name+        let counts = Self.recordCounts(line.draft)+        return ConstellationLineRow(name: name, detail: counts, showsAttention: line.isTorn) {+            editing.wrappedValue = line.id         }-        .accessibilityIdentifier("work-detail-character-line-\(id.uuidString)")+        .accessibilityIdentifier(+            RecordKindPresentation.workDetailIdentifier(kind, "-line-\(line.id.uuidString)"))         .accessibilityLabel(-            ([name, counts].compactMap { $0 } + (isTorn ? ["differing copies"] : []))+            ([name, counts].compactMap { $0 } + (line.isTorn ? ["differing copies"] : []))                 .joined(separator: ", "))-        .accessibilityHint("Opens this character's name, aliases and facts")+        .accessibilityHint(+            "Opens this \(RecordKindPresentation.noun(kind))'s name, aliases and facts")     }      /// "2 aliases · 3 facts" — the two things a draft holds that the line can-    /// count, and nothing at all for a character that holds neither.+    /// count, and nothing at all for a record that holds neither.     ///-    /// New presentation of data the editor already carried: `CharacterDraft` has+    /// New presentation of data the editor already carried: `RecordDraft` has     /// always had both, and the pill showed only the name.-    private static func characterCounts(_ draft: CharacterDraft) -> String? {+    private static func recordCounts(_ draft: RecordDraft) -> String? {         var parts: [String] = []         if !draft.aliases.isEmpty {             parts.append(Pluralisation.count(draft.aliases.count, "alias", "aliases"))@@ -2046,13 +2139,6 @@ struct WorkDetailView: View {         return parts.isEmpty ? nil : parts.joined(separator: " · ")     } -    /// 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 }-      private func openReview(workID: UUID) {         guard let extraction else { return }@@ -2065,13 +2151,30 @@ struct WorkDetailView: View {             workID: workID,             proposals: extraction.held(for: workID),             characters: model.characters,+            places: model.places,             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+            // Req 2.2: the reclassify preview's suppression and accepted-fact+            // input. Read through the closure rather than captured, so a pass+            // or a reconcile that lands while the sheet is open is what the+            // next preview sees.+            extractionContext: { extraction.context(for: workID) },+            // Q56/Q77: the **gated** field — a skip that wrote a name-key+            // suppression under both kinds also discards the row of that name+            // key held under the other one, and nothing else does.+            onDecision: { key, kinds in+                extraction.discard(key, for: workID, nameKeySuppressedKinds: kinds)+            },+            // Q66: a `.reRouted` refusal names the record 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)+            onReRoute: { key, target in+                extraction.retarget(key, for: workID, to: target)+            },+            // Req 2.2/Q28: the reader's kind and the target the preview+            // resolved go onto the held row, so the choice survives the sheet+            // closing and a later settlement's merge keeps it (Q69).+            onReclassify: { key, kind, target in+                extraction.reclassify(key, for: workID, to: kind, target: target)             },             // Q110: reconcile before answering. The refusal disclosure promises             // the list below is up to date, and this is the pass that makes it@@ -2328,14 +2431,17 @@ private struct WorkCreditPresentations: ViewModifier {     } } -/// The character editor a cast line opens (`character-extraction` Req 5.3).+/// The record editor a collection line opens (`character-extraction` Req 5.3,+/// `place-extraction` Req 3.2). /// /// Bound to the same `expandedEditCharacterID` the inline editor card used, so /// "Add a character" and a finished combine still open the character they mean-/// by assigning to it.+/// by assigning to it — and to a second binding for Places, so a sheet raised+/// from one card is never handed the other card's id. private struct WorkCharacterPresentations: ViewModifier {     let model: WorkDetailModel     @Binding var editingCharacter: UUID?+    @Binding var editingPlace: UUID?      func body(content: Content) -> some View {         content@@ -2344,12 +2450,22 @@ private struct WorkCharacterPresentations: ViewModifier {                     get: { editingCharacter.map { PresentedID(id: $0) } },                     set: { editingCharacter = $0?.id })             ) { presented in-                CharacterEditorView(model: model, characterID: presented.id) { target in+                CharacterEditorView(model: model, recordID: presented.id, kind: .character) {+                    target in                     // The source's line just left the cast; the target's editor                     // takes its place so the reader sees where everything went.                     editingCharacter = target                 }             }+            .sheet(+                item: Binding(+                    get: { editingPlace.map { PresentedID(id: $0) } },+                    set: { editingPlace = $0?.id })+            ) { presented in+                CharacterEditorView(model: model, recordID: presented.id, kind: .place) { target in+                    editingPlace = target+                }+            }     } } 
Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift Modified +273 / -37
diff --git a/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift b/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swiftindex 66f23d8..5e43a8f 100644--- a/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift+++ b/Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift@@ -71,12 +71,14 @@ struct CharacterExtractionCoordinatorTests {         recency: TimeInterval = 0,         notes: String = "Ada carried the lantern.",         covered: Bool = false,-        characters: [CharacterMatchTarget] = [],-        acceptedFacts: Set<CharacterFactIdentity> = [],-        suppressions: CharacterSuppressionIndex = .empty-    ) -> CharacterExtractionCandidate {+        characters: [MatchTarget] = [],+        places: [MatchTarget] = [],+        acceptedFacts: Set<RecordFactIdentity> = [],+        suppressions: SuppressionIndex = .empty,+        placeSuppressions: SuppressionIndex = .empty+    ) -> ExtractionCandidate {         let fingerprint = CharacterCoverageFingerprint.of(notes)-        return CharacterExtractionCandidate(+        return ExtractionCandidate(             workID: workID,             displayTitle: title,             recency: Date(timeIntervalSince1970: recency),@@ -85,19 +87,71 @@ struct CharacterExtractionCoordinatorTests {                     ref: .genericNotes, text: notes, fingerprint: fingerprint,                     coveredFingerprint: covered ? fingerprint : nil)             ],-            characters: characters,-            acceptedFactIdentities: acceptedFacts,-            suppressions: suppressions)+            records: [.character: characters, .place: places],+            acceptedFacts: [.character: acceptedFacts],+            suppressions: [.character: suppressions, .place: placeSuppressions])+    }++    /// One work whose two sources both name "Bay", so the pass can hold a+    /// dual-kind row from one response and a plain place row from the other —+    /// the shape Q56's cross-kind discard is about.+    private nonisolated static func bayCandidate(+        workID: UUID, entryID: UUID+    ) -> ExtractionCandidate {+        let notes = "Bay smiles at the harbour."+        let chapter = "The Bay is grim in winter."+        return ExtractionCandidate(+            workID: workID,+            displayTitle: "A Work",+            recency: Date(timeIntervalSince1970: 0),+            sources: [+                CharacterExtractionSource(+                    ref: .genericNotes, text: notes,+                    fingerprint: CharacterCoverageFingerprint.of(notes),+                    coveredFingerprint: nil),+                CharacterExtractionSource(+                    ref: .entry(entryID), text: chapter,+                    fingerprint: CharacterCoverageFingerprint.of(chapter),+                    coveredFingerprint: nil),+            ],+            records: [:],+            acceptedFacts: [:],+            suppressions: [:])     } +    /// The generic notes return "Bay" under both kinds — Req 1.5's union row,+    /// displayed as a character (Q20) — and the chapter returns it as a place+    /// only, which is its own row under its own key.+    private nonisolated static let bayScript: [StubCharacterExtractionModelClient.ScriptedResult] = [+        .success(ExtractionResult(+            characters: [+                ExtractedCharacter(+                    name: "Bay",+                    facts: [ExtractedFact(statement: "Bay smiles.", quote: "Bay smiles")])+            ],+            places: [+                ExtractedPlace(+                    name: "Bay",+                    facts: [ExtractedPlaceFact(statement: "The bay holds a harbour.",+                                               quote: "at the harbour")])+            ])),+        .success(ExtractionResult(+            places: [+                ExtractedPlace(+                    name: "Bay",+                    facts: [ExtractedPlaceFact(statement: "The Bay is grim.",+                                               quote: "The Bay is grim")])+            ])),+    ]+     /// 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 {+    ) -> ExtractionCandidate {         let notes = "Ada carried the lantern."         let chapter = "Brede followed."-        return CharacterExtractionCandidate(+        return ExtractionCandidate(             workID: workID,             displayTitle: "A Work",             recency: Date(timeIntervalSince1970: recency),@@ -111,30 +165,33 @@ struct CharacterExtractionCoordinatorTests {                     fingerprint: CharacterCoverageFingerprint.of(chapter),                     coveredFingerprint: nil),             ],-            characters: [],-            acceptedFactIdentities: [],-            suppressions: .empty)+            records: [:],+            acceptedFacts: [:],+            suppressions: [:])     }      private nonisolated static func result(         _ name: String = "Ada", statement: String = "Ada carried a lantern.",-        quote: String = "Ada carried the lantern"+        quote: String = "Ada carried the lantern",+        places: [ExtractedPlace] = []     ) -> ExtractionResult {-        ExtractionResult(characters: [-            ExtractedCharacter(-                name: name, facts: [ExtractedFact(statement: statement, quote: quote)])-        ])+        ExtractionResult(+            characters: [+                ExtractedCharacter(+                    name: name, facts: [ExtractedFact(statement: statement, quote: quote)])+            ],+            places: places)     }      private func makeSUT(-        candidates: [CharacterExtractionCandidate] = [],+        candidates: [ExtractionCandidate] = [],         availability: ModelAvailability = .available,         stub: StubCharacterExtractionModelClient = StubCharacterExtractionModelClient(             result: CharacterExtractionCoordinatorTests.result())     ) -> (CharacterExtractionCoordinator, MockLibraryProvider, MutableAvailabilityClient,           StubEnvironment) {         let mock = MockLibraryProvider()-        mock.characterExtractionCandidatesResult = .success(candidates)+        mock.extractionCandidatesResult = .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@@ -204,16 +261,23 @@ struct CharacterExtractionCoordinatorTests {         #expect(mock.lastCharacterExtractionWorkIDs == .some(nil))     } +    /// One request per source for **both** kinds (Q4): the combined answer+    /// changes what comes back, never what is sent (Req 1.1).     @Test("A request carries the work's title and one source's text, and nothing else")     func requestCarriesOnlyTheSource() async {         let workID = UUID()+        let notes = "Ada carried the lantern up to Kestrel Head."         let recorder = StubCharacterExtractionModelClient.Recorder()-        let stub = StubCharacterExtractionModelClient(result: Self.result(), recorder: recorder)+        let stub = StubCharacterExtractionModelClient(+            result: Self.result(places: [+                ExtractedPlace(+                    name: "Kestrel Head",+                    facts: [ExtractedPlaceFact(statement: "The lantern went up to Kestrel Head.",+                                               quote: "up to Kestrel Head")])+            ]),+            recorder: recorder)         let (coordinator, _, _, _) = makeSUT(-            candidates: [-                Self.candidate(-                    workID: workID, title: "Lanterns", notes: "Ada carried the lantern.")-            ],+            candidates: [Self.candidate(workID: workID, title: "Lanterns", notes: notes)],             stub: stub)          await coordinator.activationSweep()@@ -221,8 +285,10 @@ struct CharacterExtractionCoordinatorTests {         #expect(recorder.callCount == 1)         let request = recorder.recordedSources.first         #expect(request?.workTitle == "Lanterns")-        #expect(request?.text == "Ada carried the lantern.")+        #expect(request?.text == notes)         #expect(request?.source == .genericNotes)+        // Both kinds came back through the one request.+        #expect(Set(coordinator.held(for: workID).map(\.kind)) == [.character, .place])     }      @Test("A covered source is not re-processed")@@ -309,7 +375,7 @@ struct CharacterExtractionCoordinatorTests {         #expect(!coordinator.isModelAvailable)         #expect(recorder.callCount == 0)         // Req 1.2's read bound is not even paid for.-        #expect(mock.characterExtractionCandidatesCallCount == 0)+        #expect(mock.extractionCandidatesCallCount == 0)          // Availability is transient, so it is re-read every activation.         client.availabilityResult = .available@@ -338,6 +404,27 @@ struct CharacterExtractionCoordinatorTests {         #expect(advance?.sources.first?.fingerprint == CharacterCoverageFingerprint.of(notes))     } +    /// Req 1.4: coverage is one record per revision over **both** arrays, so+    /// "produced none" means neither kind survived grounding — not that the+    /// characters array was empty.+    @Test("A source whose characters and places both ground to nothing covers at pass time")+    func producedNoneCoversWhenBothArraysGroundAway() async {+        let workID = UUID()+        let notes = "Nothing here names anyone."+        // Neither name appears in the source, so grounding drops both (Req 1.2).+        let stub = StubCharacterExtractionModelClient(result: ExtractionResult(+            characters: [ExtractedCharacter(name: "Ada", facts: [])],+            places: [ExtractedPlace(name: "Kestrel Head", facts: [])]))+        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)+        #expect(mock.advancedCoverage.first?.sources.map(\.ref) == [.genericNotes])+    }+     @Test("A source with shown proposals is left for the decision to cover")     func shownProposalsAreNotCoveredAtPassTime() async {         let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate()])@@ -352,14 +439,14 @@ struct CharacterExtractionCoordinatorTests {         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(+        let accepted = RecordFactIdentity(             nameKey: "ada", source: .genericNotes, quote: "Ada carried the lantern")         let (coordinator, mock, _, _) = makeSUT(             candidates: [                 Self.candidate(                     workID: workID,                     characters: [-                        CharacterMatchTarget(+                        MatchTarget(                             id: UUID(), currentNameKey: "ada", retainedKey: "ada",                             aliasKeys: [], isTorn: false)                     ],@@ -414,7 +501,7 @@ struct CharacterExtractionCoordinatorTests {     @Test("A failed candidate read skips the activation in silence")     func failedCandidateReadSkipsSweep() async {         let (coordinator, mock, _, _) = makeSUT()-        mock.characterExtractionCandidatesResult = .failure(MockLibraryProvider.MockError.notConfigured)+        mock.extractionCandidatesResult = .failure(MockLibraryProvider.MockError.notConfigured)          await coordinator.activationSweep() @@ -430,7 +517,7 @@ struct CharacterExtractionCoordinatorTests {         await coordinator.activationSweep()         #expect(coordinator.hasProposals(for: workID)) -        mock.characterExtractionCandidatesResult = .success([])+        mock.extractionCandidatesResult = .success([])         await coordinator.reconcile()          #expect(!coordinator.hasProposals(for: workID))@@ -443,7 +530,7 @@ struct CharacterExtractionCoordinatorTests {         await coordinator.activationSweep()         #expect(coordinator.hasProposals(for: workID)) -        mock.characterExtractionCandidatesResult = .success([+        mock.extractionCandidatesResult = .success([             Self.candidate(workID: workID, notes: "Ada carried the lantern, and a rope.")         ])         await coordinator.reconcile()@@ -468,7 +555,7 @@ struct CharacterExtractionCoordinatorTests {          await coordinator.reconcile() -        #expect(mock.characterExtractionCandidatesCallCount == 0)+        #expect(mock.extractionCandidatesCallCount == 0)     }      // MARK: - Lifecycle@@ -479,10 +566,16 @@ struct CharacterExtractionCoordinatorTests {         let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])         await coordinator.activationSweep()         #expect(coordinator.hasProposals(for: workID))+        #expect(coordinator.context(for: workID) != nil, "the sweep retained the read")          coordinator.memoryWarning()          #expect(!coordinator.hasProposals(for: workID))+        // The cached candidate reads go with the held rows: they are the other+        // half of what the warning is about, and a context left behind would+        // feed the reclassify preview from a library state nothing is holding+        // any more.+        #expect(coordinator.context(for: workID) == nil)     }      /// Q110: the stop signal is checked **per source**, not per work.@@ -542,7 +635,7 @@ struct CharacterExtractionCoordinatorTests {             candidates: [                 Self.candidate(                     workID: workID,-                    suppressions: CharacterSuppressionIndex(+                    suppressions: SuppressionIndex(                         candidateKeys: ["ada"], factIdentities: []))             ]) @@ -554,14 +647,14 @@ struct CharacterExtractionCoordinatorTests {     @Test("A manual pass still refuses to re-propose an accepted fact")     func manualPassDedupsAcceptedFacts() async {         let workID = UUID()-        let accepted = CharacterFactIdentity(+        let accepted = RecordFactIdentity(             nameKey: "ada", source: .genericNotes, quote: "Ada carried the lantern")         let (coordinator, _, _, _) = makeSUT(             candidates: [                 Self.candidate(                     workID: workID,                     characters: [-                        CharacterMatchTarget(+                        MatchTarget(                             id: UUID(), currentNameKey: "ada", retainedKey: "ada",                             aliasKeys: [], isTorn: false)                     ],@@ -664,6 +757,50 @@ struct CharacterExtractionCoordinatorTests {         #expect(coordinator.manualOutcome(for: workID) == nil)     } +    // MARK: - The retained candidate read (Req 2.2's preview input)++    /// The reclassify preview needs the suppressed identities of the kind it is+    /// moving *to*, and the only read that has them is the candidate read the+    /// pass already made. So the coordinator keeps the last one per work rather+    /// than the sheet paying for a second fetch on a toggle.+    @Test("A pass retains the work's candidate read, and reconcile refreshes it")+    func contextRetainedByAPassAndRefreshedByReconcile() async {+        let workID = UUID()+        let placeKey = RecordNameKey.normalize("Kestrel Head")+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])++        await coordinator.activationSweep()++        let afterPass = coordinator.context(for: workID)+        #expect(afterPass != nil)+        #expect(afterPass?.suppressedNameKeys(of: .place).isEmpty == true)++        // A place skip that arrived by sync since the pass shows up on the next+        // reconcile — the preview reads the store's answer, not the pass's.+        mock.extractionCandidatesResult = .success([+            Self.candidate(+                workID: workID,+                placeSuppressions: SuppressionIndex(+                    candidateKeys: [placeKey], factIdentities: []))+        ])+        await coordinator.reconcile()++        #expect(coordinator.context(for: workID)?.suppressedNameKeys(of: .place) == [placeKey])+    }++    @Test("A work the library no longer offers loses its retained read")+    func contextDroppedWithTheWork() async {+        let workID = UUID()+        let (coordinator, mock, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()+        #expect(coordinator.context(for: workID) != nil)++        mock.extractionCandidatesResult = .success([])+        await coordinator.reconcile()++        #expect(coordinator.context(for: workID) == nil)+    }+     // MARK: - Decisions      @Test("Discarding a decided row leaves its siblings held")@@ -689,8 +826,107 @@ struct CharacterExtractionCoordinatorTests {         await coordinator.activationSweep()         #expect(coordinator.held(for: workID).count == 2) -        coordinator.discard(nameKey: "ada", for: workID)+        coordinator.discard(ProposalKey(kind: .character, nameKey: "ada"), for: workID)          #expect(coordinator.held(for: workID).map(\.nameKey) == ["brede"])     }++    /// The row's identity is its **assembled** kind and name key (Q28), so a+    /// place row and a character row of one name are two rows and one discard+    /// reaches exactly one of them.+    @Test("A single-kind decision leaves the same name's row of the other kind held")+    func discardIsPerKindByDefault() async {+        let workID = UUID(), entryID = UUID()+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.bayCandidate(workID: workID, entryID: entryID)],+            stub: StubCharacterExtractionModelClient(results: Self.bayScript))++        await coordinator.activationSweep()+        #expect(coordinator.held(for: workID).map(\.kind) == [.character, .place])++        coordinator.discard(ProposalKey(kind: .character, nameKey: "bay"), for: workID,+                            nameKeySuppressedKinds: [.character])++        #expect(coordinator.held(for: workID).map(\.kind) == [.place])+    }++    /// Q56: the reader skipped "Bay" under both kinds, and a "Bay — Place" row+    /// another source produced would contradict the suppression the commit just+    /// wrote. So a decision that suppressed the name key under both kinds+    /// sweeps every held row of that key, whichever kind it displays.+    @Test("A skip that suppressed both kinds discards every held row of that name key")+    func dualKindSkipDiscardsAcrossKinds() async {+        let workID = UUID(), entryID = UUID()+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.bayCandidate(workID: workID, entryID: entryID)],+            stub: StubCharacterExtractionModelClient(results: Self.bayScript))++        await coordinator.activationSweep()+        let dual = coordinator.held(for: workID).first { $0.kind == .character }+        #expect(dual?.returnedKinds == [.character, .place], "the union row Q56 is about")++        coordinator.discard(ProposalKey(kind: .character, nameKey: "bay"), for: workID,+                            nameKeySuppressedKinds: [.character, .place])++        #expect(coordinator.held(for: workID).isEmpty)+    }++    /// Req 2.4 and Q77, from the other end: the same union row **accepted**.+    ///+    /// `suppressedKinds` names both kinds here too — it answers "which kinds+    /// would a name-key suppression be written under", not "which were written"+    /// — so a sweep taking that field would drop the "Bay — Place" row another+    /// source produced, on a decision that suppressed nothing. The gated+    /// accessor is empty, and the place row stays held.+    @Test("An accepted union row sweeps nothing across kinds")+    func acceptedDualKindRowKeepsTheOtherKindsRow() async {+        let workID = UUID(), entryID = UUID()+        let (coordinator, _, _, _) = makeSUT(+            candidates: [Self.bayCandidate(workID: workID, entryID: entryID)],+            stub: StubCharacterExtractionModelClient(results: Self.bayScript))++        await coordinator.activationSweep()+        let dual = coordinator.held(for: workID).first { $0.kind == .character }+        let request = dual?.decisionRequest(workID: workID, action: .accept)+        #expect(request?.suppressedKinds == [.character, .place], "the ungated field, for contrast")+        #expect(request?.nameKeySuppressedKinds == [], "an accept writes no name-key suppression")++        coordinator.discard(ProposalKey(kind: .character, nameKey: "bay"), for: workID,+                            nameKeySuppressedKinds: request?.nameKeySuppressedKinds ?? [])++        #expect(coordinator.held(for: workID).map(\.kind) == [.place])+    }++    @Test("A re-routed decision re-points the row it was about, by key")+    func retargetAppliesToTheRowsKey() async {+        let workID = UUID()+        let recordID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()++        coordinator.retarget(ProposalKey(kind: .character, nameKey: "ada"), for: workID,+                             to: recordID)++        #expect(coordinator.held(for: workID).first?.target == .existing(recordID))+    }++    /// Req 2.2: the reader's choice lives with the held row for the app run, so+    /// the sheet can be closed and reopened without losing it.+    @Test("Reclassifying moves the kind the row displays and the target it was previewed against")+    func reclassifyReachesTheLedger() async {+        let workID = UUID()+        let placeID = UUID()+        let (coordinator, _, _, _) = makeSUT(candidates: [Self.candidate(workID: workID)])+        await coordinator.activationSweep()++        coordinator.reclassify(ProposalKey(kind: .character, nameKey: "ada"), for: workID,+                               to: .place, target: placeID)++        let row = coordinator.held(for: workID).first+        // Q28: the identity stays where the pass put it, so the reader's ticks+        // and strikes stay attached to the row they were made on.+        #expect(row?.kind == .character)+        #expect(row?.displayedKind == .place)+        #expect(row?.target == .existing(placeID))+    } }
Asterism/AsterismTests/CharacterReviewModelTests.swift Modified +617 / -84
diff --git a/Asterism/AsterismTests/CharacterReviewModelTests.swift b/Asterism/AsterismTests/CharacterReviewModelTests.swiftindex ccfa483..656eb34 100644--- a/Asterism/AsterismTests/CharacterReviewModelTests.swift+++ b/Asterism/AsterismTests/CharacterReviewModelTests.swift@@ -5,18 +5,27 @@ 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).+/// decisions (Reqs 2.1, 2.2, 2.5, 2.7 of `character-extraction`, Reqs 2.1–2.3+/// of `place-extraction`). /// /// 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.+/// *sends* — the displayed keys, the struck aliases, the unticked facts, the+/// kind and the previewed target — and what it does with what comes back. @Suite("CharacterReviewModel") @MainActor struct CharacterReviewModelTests {      // MARK: - Fixtures +    /// The row identity every identifier is built from: the **assembled** kind+    /// and the name key (Q28).+    private nonisolated static func rowID(+        _ nameKey: String, _ kind: RecordKind = .character+    ) -> String {+        ProposalKey(kind: kind, nameKey: nameKey).rowID+    }+     private nonisolated static func fact(         _ statement: String, quote: String, key: String = "ada",         source: SourceRef = .genericNotes@@ -25,42 +34,70 @@ struct CharacterReviewModelTests {     }      private nonisolated static func candidate(-        name: String = "Ada", key: String = "ada", aliases: [String] = [],+        name: String = "Ada", key: String = "ada", kind: RecordKind = .character,+        aliases: [String] = [],         facts: [GroundedFact]? = nil,-        target: ExtractionProposal.Target = .newCharacter,-        revisions: [SourceRef: String] = [.genericNotes: "fp-1"]+        target: ExtractionProposal.Target = .newRecord,+        revisions: [SourceRef: String] = [.genericNotes: "fp-1"],+        displayedKind: RecordKind? = nil,+        returnedKinds: Set<RecordKind>? = nil     ) -> ExtractionProposal {         ExtractionProposal(-            name: name, nameKey: key, proposedAliases: aliases, target: target,+            name: name, nameKey: key, kind: kind, proposedAliases: aliases, target: target,             facts: facts ?? [fact("Ada keeps the light.", quote: "Ada keeps the light", key: key)],-            citedRevisions: revisions)+            citedRevisions: revisions,+            displayedKind: displayedKind, returnedKinds: returnedKinds)     } -    private nonisolated static func character(-        id: UUID, name: String = "Ada", facts: [CharacterFact] = []-    ) -> WorkCharacterPresentation {-        WorkCharacterPresentation(-            id: id, name: name, note: "", aliases: [],-            nameKey: CharacterNameKey.normalize(name),+    /// One record the work already has, of either kind — what a bundle is drawn+    /// against and what a reclassify preview resolves onto.+    private nonisolated static func record(+        id: UUID, kind: RecordKind = .character, name: String = "Ada",+        nameKey: String? = nil, aliases: [String] = [], facts: [RecordFact] = []+    ) -> WorkRecordPresentation {+        WorkRecordPresentation(+            id: id, kind: kind, name: name, note: "", aliases: aliases,+            nameKey: nameKey ?? RecordNameKey.normalize(name),             facts: facts.map {-                WorkCharacterFactRow(-                    id: $0.quote, statement: $0.statement, quote: $0.quote, source: $0.source,+                WorkRecordFactRow(+                    id: $0.displayRowID, 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))+            editBasis: RecordEditBasis(+                kind: kind, recordID: id, name: name, note: "", aliases: aliases, facts: facts))+    }++    private nonisolated static func character(+        id: UUID, name: String = "Ada", facts: [RecordFact] = []+    ) -> WorkRecordPresentation {+        record(id: id, kind: .character, name: name, facts: facts)+    }++    /// The suppression half of a candidate read, as the coordinator retains it.+    private nonisolated static func context(+        suppressedFacts: [RecordKind: Set<RecordFactIdentity>] = [:]+    ) -> CharacterExtractionContext {+        CharacterExtractionContext(+            records: [:], acceptedFacts: [:], suppressedNameKeys: [:],+            suppressedFacts: suppressedFacts)     }      private func makeSUT(         workID: UUID = UUID(),         proposals: [ExtractionProposal] = [CharacterReviewModelTests.candidate()],-        characters: [WorkCharacterPresentation] = []+        characters: [WorkRecordPresentation] = [],+        places: [WorkRecordPresentation] = [],+        context: CharacterExtractionContext? = nil     ) -> (CharacterReviewModel, MockLibraryProvider) {         let mock = MockLibraryProvider()         let model = CharacterReviewModel(-            workID: workID, proposals: proposals, characters: characters, library: mock,-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })+            workID: workID, proposals: proposals, characters: characters, places: places,+            library: mock,+            extractionContext: { context },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [] })         return (model, mock)     } @@ -80,9 +117,9 @@ struct CharacterReviewModelTests {     private nonisolated static func extractionCandidate(         workID: UUID,         notes: String = "Ada keeps the light.",-        characters: [CharacterMatchTarget] = []-    ) -> CharacterExtractionCandidate {-        CharacterExtractionCandidate(+        characters: [MatchTarget] = []+    ) -> ExtractionCandidate {+        ExtractionCandidate(             workID: workID,             displayTitle: "The Lamp Room",             recency: Date(timeIntervalSince1970: 0),@@ -92,19 +129,20 @@ struct CharacterReviewModelTests {                     fingerprint: CharacterCoverageFingerprint.of(notes),                     coveredFingerprint: nil)             ],-            characters: characters,-            acceptedFactIdentities: [],-            suppressions: .empty)+            records: [.character: characters],+            acceptedFacts: [:],+            suppressions: [:])     }      private nonisolated static func workPresentation(-        workID: UUID, characters: [WorkCharacterPresentation]+        workID: UUID, characters: [WorkRecordPresentation],+        places: [WorkRecordPresentation] = []     ) -> 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)+            characters: characters, places: places)     }      /// A coordinator holding what a real sweep assembled over `library`.@@ -148,7 +186,7 @@ struct CharacterReviewModelTests {     @Test("A bundle row shows the target character's existing facts beside the proposals")     func bundleShowsExistingFacts() {         let characterID = UUID()-        let existing = CharacterFact(+        let existing = RecordFact(             statement: "Ada arrived by sea.", quote: "arrived by sea", nameKey: "ada",             source: .genericNotes)         let (model, _) = makeSUT(@@ -178,6 +216,335 @@ struct CharacterReviewModelTests {         #expect(model.rows.first?.aliases.first?.isStruck == false)     } +    // MARK: - Both kinds in one list (Req 2.1)++    @Test("A row carries the kind it is displayed under and the kind it was assembled under")+    func rowsCarryTheirKind() {+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(name: "Ada", key: "ada"),+                Self.candidate(name: "Kestrel Head", key: "kestrel head", kind: .place),+            ])++        #expect(model.rows.map(\.kind) == [.character, .place])+        #expect(model.rows.map(\.originalKind) == [.character, .place])+        // The row's identity is the proposal key's, so two rows of one name key+        // under two kinds are two rows (Q28).+        #expect(model.rows.map(\.id) == ["character:ada", "place:kestrel head"])+    }++    @Test("A union row discloses that the name was proposed under both kinds")+    func dualKindRowDiscloses() {+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(name: "Selkie", key: "selkie", returnedKinds: [.character, .place]),+                Self.candidate(name: "Ada", key: "ada"),+            ])++        #expect(model.rows.first?.isDualKind == true)+        #expect(model.rows.last?.isDualKind == false)+    }++    @Test("Only candidates and reclassification-produced bundles offer the kind control")+    func onlyCandidatesOfferTheKindControl() {+        let characterID = UUID()+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(name: "Ada", key: "ada"),+                Self.candidate(name: "Brede", key: "brede", target: .existing(characterID)),+            ],+            characters: [Self.character(id: characterID, name: "Brede")])++        #expect(model.rows.first?.canReclassify == true, "a candidate can be reclassified")+        #expect(+            model.rows.last?.canReclassify == false,+            "Req 2.2: a bundle the pass assembled offers no kind control")+    }++    // MARK: - The reclassify preview (Reqs 2.2, 2.3, Q24)++    @Test("Reclassifying onto a matching record redraws the row as a bundle at once")+    func reclassifyRedrawsACandidateAsABundle() {+        let placeID = UUID()+        let existing = RecordFact(+            statement: "The head faces north.", quote: "faces north", nameKey: "kestrel head",+            source: .genericNotes)+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head",+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head")+                    ])+            ],+            places: [+                Self.record(id: placeID, kind: .place, name: "Kestrel Head", facts: [existing])+            ])++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        let row = model.rows.first+        #expect(row?.kind == .place)+        #expect(row?.originalKind == .character)+        #expect(row?.isBundle == true)+        #expect(row?.targetID == placeID)+        #expect(row?.existingFacts.map(\.statement) == ["The head faces north."])+        #expect(row?.canReclassify == true, "Req 2.2: the control stays on a bundle it produced")+    }++    @Test("Reclassifying back redraws the bundle as the candidate it was")+    func reclassifyBackRedrawsTheCandidate() {+        let placeID = UUID()+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Kestrel Head", key: "kestrel head")],+            places: [Self.record(id: placeID, kind: .place, name: "Kestrel Head")])++        model.reclassify(Self.rowID("kestrel head"), to: .place)+        model.reclassify(Self.rowID("kestrel head"), to: .character)++        let row = model.rows.first+        #expect(row?.kind == .character)+        #expect(row?.isBundle == false)+        #expect(row?.targetID == nil)+        #expect(row?.proposedFacts.count == 1, "the facts came back with the kind")+    }++    @Test("Reclassifying re-keys the facts and dedups them against the destination's own")+    func reclassifyReKeysAndDedupsAgainstThePresentations() async {+        let placeID = UUID()+        // The place already holds this fact, under **its** retained key. The+        // proposal's copy is keyed to the character candidate's key, so only a+        // re-key before the dedup can recognise the two as one fact (Q79).+        let accepted = RecordFact(+            statement: "Kestrel Head is windswept.", quote: "windswept", nameKey: "kh-retained",+            source: .genericNotes)+        let entryID = UUID()+        let (model, mock) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head",+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head"),+                        Self.fact(+                            "Gulls nest there.", quote: "Gulls nest", key: "kestrel head",+                            source: .entry(entryID)),+                    ],+                    revisions: [.genericNotes: "fp-1", .entry(entryID): "fp-2"])+            ],+            places: [+                Self.record(+                    id: placeID, kind: .place, name: "Kestrel Head", nameKey: "kh-retained",+                    facts: [accepted])+            ])++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        #expect(+            model.rows.first?.proposedFacts.map(\.statement) == ["Gulls nest there."],+            "the accepted copy deduped away under the destination")++        await model.accept(Self.rowID("kestrel head"))++        let request = mock.committedDecisions.first+        #expect(request?.kind == .place)+        #expect(request?.displayedTargetID == placeID)+        #expect(+            request?.facts.map(\.nameKey) == ["kh-retained"],+            "Q59: the request is built from the projection, not from the held row")+    }++    @Test("Reclassifying drops facts the destination kind's suppressions cover")+    func reclassifyDropsSuppressedFacts() {+        let suppressed = RecordFactIdentity(+            nameKey: "kestrel head", source: .genericNotes, quote: "windswept")+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head",+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head")+                    ])+            ],+            context: Self.context(suppressedFacts: [.place: [suppressed]]))++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        #expect(model.rows.first?.proposedFacts.isEmpty == true)+        #expect(+            model.rows.count == 1,+            "Req 2.2: a row never leaves an open list on a toggle")+        #expect(+            model.rows.first?.canAccept == false,+            "nothing is left to accept; the skip still decides it")+    }++    /// Req 2.2/Q31 read the other way: only a row whose facts *deduped away*+    /// under the destination loses its Keep. A candidate the model returned with+    /// no facts at all (Req 1.2) had none to lose — it is the common shape for a+    /// place named in passing — and disabling Keep there would leave Skip as the+    /// only decision on the very name the reader had just re-filed.+    @Test("A reclassified name-only candidate can still be kept")+    func reclassifiedNameOnlyCandidateCanBeKept() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Kestrel Head", key: "kestrel head", facts: [])])++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        let row = model.rows.first+        #expect(row?.kind == .place)+        #expect(row?.proposedFacts.isEmpty == true)+        #expect(row?.canAccept == true, "nothing deduped away, so the name is still there to keep")+    }++    @Test("A reclassified candidate carrying only aliases can still be kept")+    func reclassifiedAliasOnlyCandidateCanBeKept() {+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head", aliases: ["The Head"], facts: [])+            ])++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        let row = model.rows.first+        #expect(row?.aliases.map(\.name) == ["The Head"])+        #expect(row?.canAccept == true, "the aliases are Q92's, and an accept installs them")+    }++    /// Q36's survival predicate counts a new alias as content worth keeping, so+    /// a reclassified row whose facts all deduped away but which still carries+    /// an unstruck alias has something to keep. Disabling Keep there threw that+    /// alias away and left Skip as the only decision on the row.+    @Test("A reclassified row whose facts deduped away keeps its Keep for its aliases")+    func reclassifiedRowWithAliasesAndNoFactsCanBeKept() {+        let suppressed = RecordFactIdentity(+            nameKey: "kestrel head", source: .genericNotes, quote: "windswept")+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head", aliases: ["The Head"],+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head")+                    ])+            ],+            context: Self.context(suppressedFacts: [.place: [suppressed]]))++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        let row = model.rows.first+        #expect(row?.kind == .place)+        #expect(+            row?.proposedFacts.isEmpty == true,+            "the fact deduped away under the destination kind")+        #expect(row?.aliases.map(\.name) == ["The Head"])+        #expect(row?.canAccept == true, "the unstruck alias is still content worth keeping")+    }++    @Test("A character suppression does not reach the character row it was toggled from")+    func suppressionsAreReadPerKind() {+        let suppressed = RecordFactIdentity(+            nameKey: "kestrel head", source: .genericNotes, quote: "windswept")+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head",+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head")+                    ])+            ],+            context: Self.context(suppressedFacts: [.character: [suppressed]]))++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        #expect(+            model.rows.first?.proposedFacts.count == 1,+            "Req 1.6: the place half is filtered against place suppressions only")+    }++    @Test("Ticks and strikes survive a reclassify")+    func ticksAndStrikesSurviveAReclassify() {+        let (model, _) = makeSUT(+            proposals: [+                Self.candidate(+                    name: "Kestrel Head", key: "kestrel head", aliases: ["The Head"],+                    facts: [+                        Self.fact(+                            "Kestrel Head is windswept.", quote: "windswept", key: "kestrel head"),+                        Self.fact("Gulls nest there.", quote: "Gulls nest", key: "kestrel head"),+                    ])+            ])+        let rowID = Self.rowID("kestrel head")+        guard let factID = model.rows.first?.proposedFacts.first?.id else {+            Issue.record("Expected a proposed fact")+            return+        }++        model.setTicked(false, factID: factID, in: rowID)+        model.setStruck(true, alias: "The Head", in: rowID)+        model.reclassify(rowID, to: .place)++        let row = model.rows.first+        #expect(row?.proposedFacts.first { $0.id == factID }?.isTicked == false)+        #expect(row?.aliases.first?.isStruck == true)+    }++    @Test("Reclassifying tells the coordinator the kind and the previewed target")+    func reclassifyTellsTheCoordinator() {+        let placeID = UUID()+        nonisolated(unsafe) var reclassified: [(ProposalKey, RecordKind, UUID?)] = []+        let model = CharacterReviewModel(+            workID: UUID(),+            proposals: [Self.candidate(name: "Kestrel Head", key: "kestrel head")],+            characters: [],+            places: [Self.record(id: placeID, kind: .place, name: "Kestrel Head")],+            library: MockLibraryProvider(),+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in },+            onReclassify: { reclassified.append(($0, $1, $2)) },+            refresh: { [] })++        model.reclassify(Self.rowID("kestrel head"), to: .place)++        #expect(reclassified.count == 1)+        #expect(reclassified.first?.0 == ProposalKey(kind: .character, nameKey: "kestrel head"))+        #expect(reclassified.first?.1 == .place)+        #expect(reclassified.first?.2 == placeID)+    }++    // MARK: - The cross-kind hint (Q41)++    @Test("A place row whose name a character already answers to says so")+    func placeRowHintsAtAnExistingCharacter() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Selkie", key: "selkie", kind: .place)],+            characters: [Self.record(id: UUID(), kind: .character, name: "Selkie")])++        #expect(model.rows.first?.crossKindHint == "You already have a character with this name")+    }++    @Test("A character row whose name a place already answers to says so")+    func characterRowHintsAtAnExistingPlace() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Selkie", key: "selkie")],+            places: [Self.record(id: UUID(), kind: .place, name: "Selkie")])++        #expect(model.rows.first?.crossKindHint == "You already have a place with this name")+    }++    @Test("No hint where the other kind holds nothing of that name")+    func noHintWithoutACrossKindMatch() {+        let (model, _) = makeSUT(+            proposals: [Self.candidate(name: "Selkie", key: "selkie")],+            places: [Self.record(id: UUID(), kind: .place, name: "Kestrel Head")])++        #expect(model.rows.first?.crossKindHint == nil)+    }+     // MARK: - Snapshot at open (Req 2.7)      @Test("A later sweep's proposals do not change the open sheet")@@ -187,7 +554,9 @@ struct CharacterReviewModelTests {         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 })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { latest })          latest.append(Self.candidate(name: "Brede", key: "brede")) @@ -224,7 +593,9 @@ struct CharacterReviewModelTests {             workID: UUID(), proposals: [proposal], characters: [],             captureOrder: [capturedFirst: 0, capturedSecond: 1],             library: MockLibraryProvider(),-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [] })          #expect(             model.rows.first?.proposedFacts.map(\.source)@@ -250,7 +621,7 @@ struct CharacterReviewModelTests {          await model.accept(row.id) -        let request = mock.committedCharacterDecisions.first+        let request = mock.committedDecisions.first         #expect(request?.facts.isEmpty == true)         #expect(request?.untickedFacts.count == 1)     }@@ -267,7 +638,7 @@ struct CharacterReviewModelTests {         model.setStruck(true, alias: "Nightjar", in: row.id)         await model.accept(row.id) -        let request = mock.committedCharacterDecisions.first+        let request = mock.committedDecisions.first         #expect(request?.proposedAliases == ["Lampkeeper"])         #expect(request?.displayedKeys == ["ada", "lampkeeper"])     }@@ -284,7 +655,7 @@ struct CharacterReviewModelTests {         model.setStruck(true, alias: "Nightjar", in: row.id)         await model.skip(row.id) -        let request = mock.committedCharacterDecisions.first+        let request = mock.committedDecisions.first         #expect(request?.action == .skip)         // The struck alias's key is not among them (Q92).         #expect(request?.displayedKeys == ["ada"])@@ -309,7 +680,7 @@ struct CharacterReviewModelTests {          await model.accept(row.id) -        let sources = mock.committedCharacterDecisions.first?.completedSources ?? []+        let sources = mock.committedDecisions.first?.completedSources ?? []         #expect(sources.count == 2)         #expect(Set(sources.map(\.ref)) == Set([.genericNotes, .entry(entryID)]))     }@@ -318,26 +689,55 @@ struct CharacterReviewModelTests {      @Test("A committed decision leaves the sheet and tells the coordinator which row it was")     func commitReportsTheDecidedRow() async {-        nonisolated(unsafe) var decided: [String] = []+        nonisolated(unsafe) var decided: [ProposalKey] = []         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: { [] })+            extractionContext: { nil },+            onDecision: { key, _ in decided.append(key) }, onReRoute: { _, _ in },+            onReclassify: { _, _, _ in },+            refresh: { [] }) -        await model.accept("ada")+        await model.accept(Self.rowID("ada")) -        #expect(decided == ["ada"])+        #expect(decided == [ProposalKey(kind: .character, nameKey: "ada")])         #expect(model.rows.map(\.name) == ["Brede"])     } +    /// Q56/Q77: the coordinator's cross-kind sweep runs on the kinds the commit+    /// **wrote** a name-key suppression under, which is the gated reading — a+    /// candidate skip and nothing else.+    @Test("A skipped union row reports both suppressed kinds; an accepted one reports none")+    func skipOfAUnionRowReportsBothKinds() async {+        nonisolated(unsafe) var reported: [Set<RecordKind>] = []+        let mock = MockLibraryProvider()+        let model = CharacterReviewModel(+            workID: UUID(),+            proposals: [+                Self.candidate(name: "Selkie", key: "selkie", returnedKinds: [.character, .place]),+                Self.candidate(name: "Ada", key: "ada", returnedKinds: [.character, .place]),+            ],+            characters: [], library: mock,+            extractionContext: { nil },+            onDecision: { _, kinds in reported.append(kinds) }, onReRoute: { _, _ in },+            onReclassify: { _, _, _ in },+            refresh: { [] })++        await model.skip(Self.rowID("selkie"))+        await model.accept(Self.rowID("ada"))++        #expect(reported.first == [.character, .place])+        #expect(reported.last?.isEmpty == true, "Req 2.4: an accept never touches the other kind")+    }+     @Test("Deciding the last row empties the sheet")     func lastDecisionEmptiesTheSheet() async {         let (model, _) = makeSUT() -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.isEmpty)     }@@ -348,7 +748,7 @@ struct CharacterReviewModelTests {             proposals: [Self.candidate(name: "Ada", key: "ada"),                         Self.candidate(name: "Brede", key: "brede")]) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))         model.dismiss()          // Nothing was discarded on the way out: the coordinator still holds@@ -368,16 +768,16 @@ struct CharacterReviewModelTests {          // Hold the first commit in flight until the test releases it.         let (gate, release) = AsyncStream.makeStream(of: Void.self)-        mock.commitCharacterDecisionHold = { for await _ in gate {} }+        mock.commitDecisionHold = { for await _ in gate {} } -        let first = Task { await model.accept("ada") }-        while mock.committedCharacterDecisions.isEmpty { await Task.yield() }+        let first = Task { await model.accept(Self.rowID("ada")) }+        while mock.committedDecisions.isEmpty { await Task.yield() }         #expect(model.isSubmitting)          // The second tap commits nothing and decides nothing.-        await model.accept("brede")-        #expect(mock.committedCharacterDecisions.count == 1)-        #expect(model.rows.contains { $0.id == "brede" })+        await model.accept(Self.rowID("brede"))+        #expect(mock.committedDecisions.count == 1)+        #expect(model.rows.contains { $0.id == Self.rowID("brede") })          release.finish()         await first.value@@ -396,7 +796,7 @@ struct CharacterReviewModelTests {     func staleRefusalInvalidatesTheRow() async {         let workID = UUID()         let mock = MockLibraryProvider()-        mock.characterExtractionCandidatesResult = .success([+        mock.extractionCandidatesResult = .success([             Self.extractionCandidate(workID: workID)         ])         let coordinator = await sweptCoordinator(library: mock)@@ -405,23 +805,27 @@ struct CharacterReviewModelTests {         let model = CharacterReviewModel(             workID: workID, proposals: coordinator.held(for: workID), characters: [],             library: mock,-            onDecision: { coordinator.discard(nameKey: $0, for: workID) },+            extractionContext: { nil },+            onDecision: { key, kinds in+                coordinator.discard(key, for: workID, nameKeySuppressedKinds: kinds)+            },             onReRoute: { key, target in-                coordinator.retarget(nameKey: key, for: workID, to: target)+                coordinator.retarget(key, for: workID, to: target)             },+            onReclassify: { _, _, _ in },             refresh: {                 await coordinator.reconcile()                 return coordinator.held(for: workID)             })          // The note the proposal cites has been edited since it was assembled.-        mock.characterExtractionCandidatesResult = .success([+        mock.extractionCandidatesResult = .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)))+        mock.commitDecisionResult = .success(.refused(.staleSource(.genericNotes))) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.disclosure?.contains("changed") == true)         #expect(model.isEmpty, "the stale row is gone from the sheet")@@ -439,19 +843,19 @@ struct CharacterReviewModelTests {         // 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([+        mock.extractionCandidatesResult = .success([             Self.extractionCandidate(                 workID: workID,                 characters: [-                    CharacterMatchTarget(+                    MatchTarget(                         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)+        #expect(coordinator.held(for: workID).first?.target == .newRecord) -        let existing = CharacterFact(+        let existing = RecordFact(             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@@ -459,10 +863,14 @@ struct CharacterReviewModelTests {         let model = CharacterReviewModel(             workID: workID, proposals: coordinator.held(for: workID), characters: [],             library: mock,-            onDecision: { coordinator.discard(nameKey: $0, for: workID) },+            extractionContext: { nil },+            onDecision: { key, kinds in+                coordinator.discard(key, for: workID, nameKeySuppressedKinds: kinds)+            },             onReRoute: { key, target in-                coordinator.retarget(nameKey: key, for: workID, to: target)+                coordinator.retarget(key, for: workID, to: target)             },+            onReclassify: { _, _, _ in },             refresh: {                 await coordinator.reconcile()                 return coordinator.held(for: workID)@@ -473,9 +881,9 @@ struct CharacterReviewModelTests {                 characters: [                     Self.character(id: characterID, name: "Ada Vance", facts: [existing])                 ]))-        mock.commitCharacterDecisionResult = .success(.refused(.reRouted(to: characterID)))+        mock.commitDecisionResult = .success(.refused(.reRouted(to: characterID))) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.disclosure != nil)         #expect(@@ -489,15 +897,131 @@ struct CharacterReviewModelTests {         #expect(row?.existingFacts.map(\.statement) == ["Ada arrived by sea."])     } +    /// Q24/Q59: a re-route refused under the **displayed** kind comes back as a+    /// bundle of that kind, drawn against the places the refresh re-read — the+    /// reclassified row must not be redrawn as the character candidate it was+    /// assembled as.+    @Test("A re-routed refusal after a reclassify refreshes as a bundle of the displayed kind")+    func reRoutedRefusalAfterReclassifyRefreshesAsAPlaceBundle() async {+        let workID = UUID(), placeID = UUID()+        let held = Self.candidate(name: "Kestrel Head", key: "kestrel head")+        nonisolated(unsafe) var latest = [held]+        let mock = MockLibraryProvider()+        let existing = RecordFact(+            statement: "The head faces north.", quote: "faces north", nameKey: "kestrel head",+            source: .genericNotes)+        let place = Self.record(+            id: placeID, kind: .place, name: "Kestrel Head", facts: [existing])+        let model = CharacterReviewModel(+            workID: workID, proposals: latest, characters: [], places: [], library: mock,+            extractionContext: { nil },+            onDecision: { _, _ in },+            onReRoute: { key, target in+                // What the coordinator does: the held row is re-pointed, keeping+                // the reader's displayed kind.+                latest = latest.map { proposal in+                    guard proposal.key == key else { return proposal }+                    var moved = proposal+                    moved.displayedKind = .place+                    moved.target = target.map { .existing($0) } ?? .newRecord+                    return moved+                }+            },+            onReclassify: { _, _, _ in },+            refresh: { latest })+        mock.workDetailResult = .success(+            Self.workPresentation(workID: workID, characters: [], places: [place]))+        mock.commitDecisionResult = .success(.refused(.reRouted(to: placeID)))++        model.reclassify(Self.rowID("kestrel head"), to: .place)+        await model.accept(Self.rowID("kestrel head"))++        let row = model.rows.first+        #expect(model.rows.count == 1)+        #expect(row?.kind == .place)+        #expect(row?.isBundle == true)+        #expect(row?.targetID == placeID)+        #expect(row?.existingFacts.map(\.statement) == ["The head faces north."])+    }++    /// Q69: the ledger's merge keeps the reader's kind and the **older** row's+    /// previewed target, because it cannot preview one itself. The sheet is+    /// where that target is replaced: the re-preview runs under the displayed+    /// kind over the merged content.+    @Test("A merged row is re-previewed under the reader's kind over the merged content")+    func mergedRowIsRePreviewedUnderTheReadersKind() async {+        let workID = UUID()+        let goneID = UUID(), placeID = UUID()+        let entryID = UUID()+        var ledger = CharacterExtractionLedger()+        let older = Self.candidate(+            name: "Kestrel Head", key: "kestrel head",+            facts: [+                Self.fact("Kestrel Head is windswept.", quote: "windswept", key: "kestrel head")+            ])+        let newer = Self.candidate(+            name: "Kestrel Head", key: "kestrel head",+            facts: [+                Self.fact(+                    "Gulls nest there.", quote: "Gulls nest", key: "kestrel head",+                    source: .entry(entryID))+            ],+            revisions: [.entry(entryID): "fp-2"])++        let first = ExtractionSourceKey(work: workID, source: .genericNotes)+        _ = ledger.start(+            first, fingerprint: "fp-1", pass: .manual, environment: ModelWorkEnvironment())+        ledger.settle(first, .proposals([older]), modelPhase: .zero)+        // The reader reclassified before the second source settled, and the+        // preview resolved onto a place that has since gone.+        ledger.reclassify(older.key, for: workID, to: .place, target: goneID)+        let second = ExtractionSourceKey(work: workID, source: .entry(entryID))+        _ = ledger.start(+            second, fingerprint: "fp-2", pass: .manual, environment: ModelWorkEnvironment())+        ledger.settle(second, .proposals([newer]), modelPhase: .zero)++        let merged = ledger.held(for: workID)+        #expect(merged.first?.displayedKind == .place, "the ledger kept the reader's kind")+        #expect(merged.first?.target == .existing(goneID), "and the older row's target")++        nonisolated(unsafe) var reclassified: [(ProposalKey, RecordKind, UUID?)] = []+        let model = CharacterReviewModel(+            workID: workID, proposals: merged, characters: [],+            places: [Self.record(id: placeID, kind: .place, name: "Kestrel Head")],+            library: MockLibraryProvider(),+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in },+            onReclassify: { reclassified.append(($0, $1, $2)) },+            refresh: { [] })++        let row = model.rows.first+        #expect(row?.kind == .place)+        #expect(+            row?.targetID == placeID,+            "Q69: the sheet re-previews the merged row and replaces the ledger's target")+        #expect(+            row?.proposedFacts.count == 2,+            "over the merged content — both sources' facts")+        // The other half of Q69: the held row is told, so the ledger's stale+        // target does not outlive the sheet. Without the write-back the next+        // reconcile judges the row against a place that has gone.+        #expect(reclassified.count == 1)+        #expect(reclassified.first?.0 == older.key)+        #expect(reclassified.first?.1 == .place)+        #expect(reclassified.first?.2 == placeID)+    }+     @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())))+        mock.commitDecisionResult = .success(.refused(.torn(recordID: UUID())))         let model = CharacterReviewModel(             workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [Self.candidate()] })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [Self.candidate()] }) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.disclosure != nil)         #expect(model.routesToCheckLibrary)@@ -508,26 +1032,30 @@ struct CharacterReviewModelTests {     @Test("A torn work still takes a skip")     func tornWorkStillTakesASkip() async {         let mock = MockLibraryProvider()-        mock.commitCharacterDecisionResult = .success(.committed(characterID: nil))+        mock.commitDecisionResult = .success(.committed(recordID: nil))         let model = CharacterReviewModel(             workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [] }) -        await model.skip("ada")+        await model.skip(Self.rowID("ada"))          #expect(model.isEmpty)-        #expect(mock.committedCharacterDecisions.first?.action == .skip)+        #expect(mock.committedDecisions.first?.action == .skip)     }      @Test("A vanished work closes the sheet")     func workGoneClosesTheSheet() async {         let mock = MockLibraryProvider()-        mock.commitCharacterDecisionResult = .success(.refused(.workGone))+        mock.commitDecisionResult = .success(.refused(.workGone))         let model = CharacterReviewModel(             workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [] })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [] }) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.isEmpty)     }@@ -535,12 +1063,14 @@ struct CharacterReviewModelTests {     @Test("A thrown commit keeps the row and says so")     func thrownCommitKeepsTheRow() async {         let mock = MockLibraryProvider()-        mock.commitCharacterDecisionResult = .failure(MockLibraryProvider.MockError.notConfigured)+        mock.commitDecisionResult = .failure(MockLibraryProvider.MockError.notConfigured)         let model = CharacterReviewModel(             workID: UUID(), proposals: [Self.candidate()], characters: [], library: mock,-            onDecision: { _ in }, onReRoute: { _, _ in }, refresh: { [Self.candidate()] })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { [Self.candidate()] }) -        await model.accept("ada")+        await model.accept(Self.rowID("ada"))          #expect(model.disclosure != nil)         #expect(model.rows.count == 1)@@ -561,18 +1091,21 @@ struct CharacterReviewModelTests {         ]         let workID = UUID()         let mock = MockLibraryProvider()-        mock.commitCharacterDecisionResult = .success(.refused(.staleSource(.genericNotes)))+        mock.commitDecisionResult = .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 })+            extractionContext: { nil },+            onDecision: { _, _ in }, onReRoute: { _, _ in }, onReclassify: { _, _, _ in },+            refresh: { held }) -        model.setStruck(true, alias: "Nightjar", in: "ada")-        await model.accept("ada")+        model.setStruck(true, alias: "Nightjar", in: Self.rowID("ada"))+        await model.accept(Self.rowID("ada")) -        #expect(model.rows.first { $0.id == "ada" }?.aliases.first?.isStruck == true)         #expect(-            model.rows.first { $0.id == "brede" }?.aliases.first?.isStruck == false,+            model.rows.first { $0.id == Self.rowID("ada") }?.aliases.first?.isStruck == true)+        #expect(+            model.rows.first { $0.id == Self.rowID("brede") }?.aliases.first?.isStruck == false,             "Brede's alias is Brede's decision")     } @@ -580,10 +1113,10 @@ struct CharacterReviewModelTests {     func duplicateSubmissionSuppressed() async {         let (model, mock) = makeSUT() -        async let first: Void = model.accept("ada")-        await model.accept("ada")+        async let first: Void = model.accept(Self.rowID("ada"))+        await model.accept(Self.rowID("ada"))         await first -        #expect(mock.committedCharacterDecisions.count == 1)+        #expect(mock.committedDecisions.count == 1)     } }
Asterism/AsterismTests/Helpers/MockLibraryProvider.swift Modified +35 / -35
diff --git a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swiftindex fb3b83a..d76e372 100644--- a/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift+++ b/Asterism/AsterismTests/Helpers/MockLibraryProvider.swift@@ -273,18 +273,18 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     /// 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)-    /// Awaited inside `commitCharacterDecision` before it returns, so a test+    var extractionCandidatesResult:+        Result<[ExtractionCandidate], Error> = .success([])+    var advanceCoverageResult: Result<Int, Error> = .success(1)+    /// Awaited inside `commitDecision` before it returns, so a test     /// can hold one decision in flight while probing the model's gate.-    var commitCharacterDecisionHold: (@Sendable () async -> Void)?-    var commitCharacterDecisionResult: Result<CharacterDecisionOutcome, Error> =-        .success(.committed(characterID: nil))-    var commitCharacterEditsResult: Result<CharacterEditOutcome, Error> =-        .success(.committed(characterIDs: []))+    var commitDecisionHold: (@Sendable () async -> Void)?+    var commitDecisionResult: Result<DecisionOutcome, Error> =+        .success(.committed(recordID: nil))+    var commitRecordEditsResult: Result<RecordEditOutcome, Error> =+        .success(.committed(recordIDs: [])) -    var characterExtractionCandidatesCallCount: Int {+    var extractionCandidatesCallCount: Int {         recorderLock.withLock { storedCharacterExtractionCallCount }     }     var lastCharacterExtractionLimit: Int? {@@ -297,33 +297,33 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     }     /// 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])] {+    var advancedCoverage: [(workID: UUID, sources: [CompletedSource])] {         recorderLock.withLock { storedAdvancedCoverage }     }-    var committedCharacterDecisions: [CharacterDecisionRequest] {+    var committedDecisions: [DecisionRequest] {         recorderLock.withLock { storedCharacterDecisions }     }-    var committedCharacterEdits: [(workID: UUID, operations: [CharacterEditOperation])] {+    var committedRecordEdits: [(workID: UUID, operations: [RecordEditOperation])] {         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])] = []+    private var storedAdvancedCoverage: [(workID: UUID, sources: [CompletedSource])] = []+    private var storedCharacterDecisions: [DecisionRequest] = []+    private var storedCharacterEdits: [(workID: UUID, operations: [RecordEditOperation])] = [] -    func characterExtractionCandidates(+    func extractionCandidates(         limit: Int, workIDs: Set<UUID>?-    ) async throws -> [CharacterExtractionCandidate] {+    ) async throws -> [ExtractionCandidate] {         recorderLock.withLock {             storedCharacterExtractionCallCount += 1             storedLastCharacterExtractionLimit = limit             storedLastCharacterExtractionWorkIDs = workIDs-            callLog.append("characterExtractionCandidates")+            callLog.append("extractionCandidates")         }-        let rows = try characterExtractionCandidatesResult.get()+        let rows = try extractionCandidatesResult.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@@ -338,35 +338,35 @@ final class MockLibraryProvider: LibraryProviding, @unchecked Sendable {     }      @discardableResult-    func advanceCharacterCoverage(-        workID: UUID, sources: [CharacterCompletedSource]+    func advanceCoverage(+        workID: UUID, sources: [CompletedSource]     ) async throws -> Int {         recorderLock.withLock {             storedAdvancedCoverage.append((workID: workID, sources: sources))-            callLog.append("advanceCharacterCoverage")+            callLog.append("advanceCoverage")         }-        return try advanceCharacterCoverageResult.get()+        return try advanceCoverageResult.get()     } -    func commitCharacterDecision(-        _ request: CharacterDecisionRequest-    ) async throws -> CharacterDecisionOutcome {+    func commitDecision(+        _ request: DecisionRequest+    ) async throws -> DecisionOutcome {         recorderLock.withLock {             storedCharacterDecisions.append(request)-            callLog.append("commitCharacterDecision")+            callLog.append("commitDecision")         }-        if let hold = commitCharacterDecisionHold { await hold() }-        return try commitCharacterDecisionResult.get()+        if let hold = commitDecisionHold { await hold() }+        return try commitDecisionResult.get()     } -    func commitCharacterEdits(-        workID: UUID, operations: [CharacterEditOperation]-    ) async throws -> CharacterEditOutcome {+    func commitRecordEdits(+        workID: UUID, operations: [RecordEditOperation]+    ) async throws -> RecordEditOutcome {         recorderLock.withLock {             storedCharacterEdits.append((workID: workID, operations: operations))-            callLog.append("commitCharacterEdits")+            callLog.append("commitRecordEdits")         }-        return try commitCharacterEditsResult.get()+        return try commitRecordEditsResult.get()     }      // MARK: - Work types
Asterism/AsterismTests/IntegrationSafetyNetTests.swift Modified +17 / -17
diff --git a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift b/Asterism/AsterismTests/IntegrationSafetyNetTests.swiftindex b6c1010..f4674d5 100644--- a/Asterism/AsterismTests/IntegrationSafetyNetTests.swift+++ b/Asterism/AsterismTests/IntegrationSafetyNetTests.swift@@ -150,10 +150,10 @@ struct IntegrationSafetyNetTests {         )          let stagingDirectory = fixture.baseDirectory.appending(path: "validated-backups")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: stagingDirectory)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: stagingDirectory)         let exportedAt = Date(timeIntervalSince1970: 1_784_246_400)         let result = try await exporter.export(-            metadata: BackupV11Metadata(+            metadata: BackupV12Metadata(                 appBuild: "integration-1",                 exportedAt: exportedAt             )@@ -161,8 +161,8 @@ struct IntegrationSafetyNetTests {         defer { exporter.cleanup(result) }          let encoded = try Data(contentsOf: result.fileURL)-        let decoded = try BackupV11Codec.decode(encoded)-        let source = try await repository.backupV11Snapshot()+        let decoded = try BackupV12Codec.decode(encoded)+        let source = try await repository.backupV12Snapshot()          #expect(decoded.payload == source)         #expect(decoded.payload.entries.count == 1)@@ -235,13 +235,13 @@ struct IntegrationSafetyNetTests {                     Issue.record("Expected Backup export for \(environment) \(capabilities.gate.rawValue)")                     continue                 }-                // Settings writes 11/12 now (Req 13.1): the archive has to carry+                // Settings writes 12/13 now (Req 13.1): the archive has to carry                 // a Work's site memberships, the reader's dismissed pairs,                 // version-free citations and the Work's two statuses and                 // verdict, so the round-trip is reachable from the surface the                 // reader uses. The gate the file declares is still the running                 // one.-                let document = try BackupV11Codec.decode(Data(contentsOf: backupURL))+                let document = try BackupV12Codec.decode(Data(contentsOf: backupURL))                 #expect(document.capabilityGate == AsterismCapabilities.current.gate.rawValue)                 backup.handleShareCancellation()             }@@ -326,9 +326,9 @@ struct IntegrationSafetyNetTests {         try await sourceRepo.moveEntry(entry.id, to: .existing(work.id))          let stagingDir = fixture.baseDirectory.appending(path: "export-stage")-        let exporter = BackupV11Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+        let exporter = BackupV12Exporter(repository: sourceRepo, stagingDirectory: stagingDir)         let exportResult = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "fill-test", exportedAt: Date())+            metadata: BackupV12Metadata(appBuild: "fill-test", exportedAt: Date())         )         defer { exporter.cleanup(exportResult) }         let backupData = try Data(contentsOf: exportResult.fileURL)@@ -391,9 +391,9 @@ struct IntegrationSafetyNetTests {             )         )         let stagingDir = fixture.baseDirectory.appending(path: "restore-stage")-        let exporter = BackupV11Exporter(repository: sourceRepo, stagingDirectory: stagingDir)+        let exporter = BackupV12Exporter(repository: sourceRepo, stagingDirectory: stagingDir)         let exportResult = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "restore-test", exportedAt: Date())+            metadata: BackupV12Metadata(appBuild: "restore-test", exportedAt: Date())         )         defer { exporter.cleanup(exportResult) }         let plan = try BackupImporter.plan(from: try Data(contentsOf: exportResult.fileURL))@@ -899,15 +899,15 @@ struct IntegrationSafetyNetTests {         )          let stagingDir = fixture.baseDirectory.appending(path: "corrupt-stage")-        let exporter = BackupV11Exporter(repository: repo, stagingDirectory: stagingDir)+        let exporter = BackupV12Exporter(repository: repo, stagingDirectory: stagingDir)         let exportResult = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "corrupt-test", exportedAt: Date())+            metadata: BackupV12Metadata(appBuild: "corrupt-test", exportedAt: Date())         )         defer { exporter.cleanup(exportResult) }          // Verify good backup decodes         let goodData = try Data(contentsOf: exportResult.fileURL)-        let decoded = try BackupV11Codec.decode(goodData)+        let decoded = try BackupV12Codec.decode(goodData)         #expect(decoded.payload.entries.count == 1)          // Corrupt the data by flipping bytes in the payload area@@ -920,7 +920,7 @@ struct IntegrationSafetyNetTests {          // Corrupted backup should fail decode/checksum         do {-            _ = try BackupV11Codec.decode(corruptData)+            _ = try BackupV12Codec.decode(corruptData)             Issue.record("Expected corrupted backup to fail validation")         } catch {             // Expected: checksum or decode failure@@ -1007,13 +1007,13 @@ struct IntegrationSafetyNetTests {          // Export the archive         let stagingDir = fixture.baseDirectory.appending(path: "url-backup-stage")-        let exporter = BackupV11Exporter(repository: repo, stagingDirectory: stagingDir)+        let exporter = BackupV12Exporter(repository: repo, stagingDirectory: stagingDir)         let exportResult = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "url-backup-test", exportedAt: Date())+            metadata: BackupV12Metadata(appBuild: "url-backup-test", exportedAt: Date())         )         defer { exporter.cleanup(exportResult) }         let backupData = try Data(contentsOf: exportResult.fileURL)-        let decoded = try BackupV11Codec.decode(backupData)+        let decoded = try BackupV12Codec.decode(backupData)          // Site should be present in the payload.         let site = decoded.payload.sites.first { $0.hostname == "backupurl.test" }
Asterism/AsterismTests/SettingsBackupModelTests.swift Modified +20 / -20
diff --git a/Asterism/AsterismTests/SettingsBackupModelTests.swift b/Asterism/AsterismTests/SettingsBackupModelTests.swiftindex 8e6e356..d2088ad 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(-            BackupV11ExportError.tornGroups(+            BackupV12ExportError.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(-            BackupV11ExportError.tornGroups(+            BackupV12ExportError.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(-            BackupV11ExportError.tornGroups(+            BackupV12ExportError.tornGroups(                 TornGroupsPayload(                     count: 1,                     blockingWorkSet: DuplicateSetKey(@@ -288,7 +288,7 @@ struct SettingsBackupModelTests {          let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV11ExportError.tornGroups(+            BackupV12ExportError.tornGroups(                 TornGroupsPayload(count: 1, blockingWorkSet: nil)))         let model = SettingsBackupModel(exporter: mock)         await model.startExport()@@ -302,14 +302,14 @@ struct SettingsBackupModelTests {         #expect(!model.routesToCheckLibrary)     } -    // MARK: - Archive generation 11/12 (work-creators Req 9.1)+    // MARK: - Archive generation 12/13 (place-extraction Req 5.1)      /// The Settings surface is the only place the app *writes* an archive, so a     /// seam left asking for an older generation than the repository writes would     /// leave the round-trip `multi-site-works` Req 9.2 promises unreachable. The     /// metadata type is the tell: the exporter this model holds is the one whose     /// payload carries the creator, role and credit tables.-    @Test("The export surface asks the 11/12 exporter for the archive")+    @Test("The export surface asks the 12/13 exporter for the archive")     @MainActor func exportsArchiveGenerationNineTen() async {         let tempDir = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)         try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)@@ -323,7 +323,7 @@ struct SettingsBackupModelTests {         let model = SettingsBackupModel(exporter: mock)         await model.startExport() -        let metadata: BackupV11Metadata? = mock.lastMetadata+        let metadata: BackupV12Metadata? = mock.lastMetadata         #expect(metadata != nil)         #expect(metadata?.appBuild.isEmpty == false)     }@@ -331,14 +331,14 @@ struct SettingsBackupModelTests {     /// Req 6.5, through Q105: a torn **character** group refuses the export the     /// same way a torn Work or Entry does, and the reader is sent to the same     /// place. The payload carries a count, not a record kind, so what this pins-    /// is that the 11/12 refusal reaches a message arm at all — an unhandled case+    /// is that the 12/13 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 11/12 torn refusal routes the reader to Check Library")+    @Test("A 12/13 torn refusal routes the reader to Check Library")     @MainActor func nineTenTornRefusalRoutes() async {         let mock = MockBackupExporting()         mock.exportResult = .failure(-            BackupV11ExportError.tornGroups(+            BackupV12ExportError.tornGroups(                 TornGroupsPayload(count: 2, blockingWorkSet: nil)))          let model = SettingsBackupModel(exporter: mock)@@ -351,18 +351,18 @@ struct SettingsBackupModelTests {         #expect(model.routesToCheckLibrary)     } -    /// Every case of the 11/12 refusal has a message of its own. A case that fell+    /// Every case of the 12/13 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 11/12 export refusal has its own message", arguments: [-        BackupV11ExportError.referencesStillArriving(detail: "rule 1"),-        BackupV11ExportError.unrepresentableValue(+    @Test("Every 12/13 export refusal has its own message", arguments: [+        BackupV12ExportError.referencesStillArriving(detail: "rule 1"),+        BackupV12ExportError.unrepresentableValue(             record: "Character", field: "factsData", value: "…"),-        BackupV11ExportError.snapshotFailed(reason: "read"),-        BackupV11ExportError.encodingFailed(reason: "encode"),-        BackupV11ExportError.stagingFailed(reason: "stage"),+        BackupV12ExportError.snapshotFailed(reason: "read"),+        BackupV12ExportError.encodingFailed(reason: "encode"),+        BackupV12ExportError.stagingFailed(reason: "stage"),     ])-    @MainActor func everyNineTenRefusalHasAMessage(error: BackupV11ExportError) async {+    @MainActor func everyNineTenRefusalHasAMessage(error: BackupV12ExportError) async {         let mock = MockBackupExporting()         mock.exportResult = .failure(error) @@ -397,12 +397,12 @@ final class MockBackupExporting: BackupExporting, @unchecked Sendable {     var cleanupCallCount = 0     var scavengeCallCount = 0     var lastCleanupURL: URL?-    var lastMetadata: BackupV11Metadata?+    var lastMetadata: BackupV12Metadata?      var exportResult: Result<BackupExportResult, Error> = .failure(MockBackupError.notConfigured)     var exportDelay: Duration? -    func export(metadata: BackupV11Metadata) async throws -> BackupExportResult {+    func export(metadata: BackupV12Metadata) async throws -> BackupExportResult {         exportCallCount += 1         lastMetadata = metadata         if let delay = exportDelay {
Asterism/AsterismTests/SettingsImportTests.swift Modified +6 / -6
diff --git a/Asterism/AsterismTests/SettingsImportTests.swift b/Asterism/AsterismTests/SettingsImportTests.swiftindex 86d73f2..801065b 100644--- a/Asterism/AsterismTests/SettingsImportTests.swift+++ b/Asterism/AsterismTests/SettingsImportTests.swift@@ -62,14 +62,14 @@ enum MockSetupError: Error, LocalizedError { @Suite("SettingsBackupImportModel") struct SettingsBackupImportModelTests { -    /// A real 11/12 document, because the model plans the bytes it is handed —+    /// A real 12/13 document, because the model plans the bytes it is handed —     /// stubbing the planner would leave the preview untested.     static let minimalBackupData: Data = {         let hostname = "settings-import.example"         let rawURL = "https://\(hostname)/read?chapter=1"-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: [-                BackupV11Entry(+                BackupV12Entry(                     id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host,                     rawURL: rawURL, canonicalURL: nil, hostname: hostname,                     entryIdentityKey: rawURL,@@ -84,14 +84,14 @@ struct SettingsBackupImportModelTests {             ],             works: [],             sites: [-                BackupV11Site(+                BackupV12Site(                     hostname: hostname, displayName: hostname, mode: .untaught,                     junkSuffixRule: nil)             ],             titlePatterns: [], urlRules: [], workTypes: [])-        return try! BackupV11Codec.encode(+        return try! BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(+            metadata: BackupV12Metadata(                 appBuild: "test", exportedAt: Date(timeIntervalSince1970: 1_800_000_000)))     }() 
Asterism/AsterismTests/WorkDetailCharacterTests.swift Modified +667 / -85
diff --git a/Asterism/AsterismTests/WorkDetailCharacterTests.swift b/Asterism/AsterismTests/WorkDetailCharacterTests.swiftindex 66a1896..dfab915 100644--- a/Asterism/AsterismTests/WorkDetailCharacterTests.swift+++ b/Asterism/AsterismTests/WorkDetailCharacterTests.swift@@ -5,6 +5,93 @@ import SwiftData import Testing @testable import Asterism +/// Fixtures shared by the record suites below: one presentation of either kind,+/// and a model wired to a mock that records what the session commits.+enum RecordSessionFixtures {++    nonisolated static func fact(+        _ statement: String, quote: String, key: String = "ada",+        source: SourceRef = .genericNotes+    ) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: key, source: source)+    }++    nonisolated static func record(+        _ kind: RecordKind, id: UUID = UUID(), name: String = "Ada", note: String = "",+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        WorkRecordPresentation(+            id: id, kind: kind, name: name, note: note, aliases: aliases,+            nameKey: RecordNameKey.normalize(name),+            facts: facts.map {+                WorkRecordFactRow(+                    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: RecordEditBasis(+                kind: kind, recordID: id, name: name, note: note, aliases: aliases,+                facts: facts))+    }++    nonisolated static func character(+        id: UUID = UUID(), name: String = "Ada", note: String = "",+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        record(+            .character, id: id, name: name, note: note, aliases: aliases, facts: facts,+            isTorn: isTorn)+    }++    nonisolated static func place(+        id: UUID = UUID(), name: String = "Kestrel Head", note: String = "",+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        record(+            .place, id: id, name: name, note: note, aliases: aliases, facts: facts,+            isTorn: isTorn)+    }++    nonisolated static func presentation(+        workID: UUID, characters: [WorkRecordPresentation],+        places: [WorkRecordPresentation] = []+    ) -> 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,+            places: places)+    }++    @MainActor+    static func makeSUT(+        workID: UUID = UUID(),+        characters: [WorkRecordPresentation] = [],+        places: [WorkRecordPresentation] = []+    ) -> (WorkDetailModel, MockLibraryProvider) {+        let mock = MockLibraryProvider()+        mock.workDetailResult = .success(+            presentation(workID: workID, characters: characters, places: places))+        // The mutation callback writes into the same log the mock's own calls do,+        // which is the only way to assert the commit *order* Q55 fixed: one+        // record step, then `onMutation()`, then one reload.+        let model = WorkDetailModel(+            workID: workID, library: mock,+            onMutation: { mock.callLog.append("mutation") })+        return (model, mock)+    }++    /// The commit-relevant slice of the mock's call log, with the reads every+    /// screen makes filtered out.+    @MainActor+    static func commitLog(_ mock: MockLibraryProvider) -> [String] {+        mock.callLog.filter { ["workDetail", "commitRecordEdits", "mutation"].contains($0) }+    }+}+ /// 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.@@ -21,47 +108,23 @@ struct WorkDetailCharacterTests {      private nonisolated static func fact(         _ statement: String, quote: String, source: SourceRef = .genericNotes-    ) -> CharacterFact {-        CharacterFact(statement: statement, quote: quote, nameKey: "ada", source: source)+    ) -> RecordFact {+        RecordSessionFixtures.fact(statement, quote: quote, 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)+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        RecordSessionFixtures.character(+            id: id, name: name, note: note, aliases: aliases, facts: facts, isTorn: isTorn)     }      private func makeSUT(         workID: UUID = UUID(),-        characters: [WorkCharacterPresentation] = []+        characters: [WorkRecordPresentation] = []     ) -> (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)+        RecordSessionFixtures.makeSUT(workID: workID, characters: characters)     }      // MARK: - Display (Reqs 5.1, 5.2)@@ -97,7 +160,7 @@ struct WorkDetailCharacterTests {          await model.load() -        let draft = model.characterDraft(for: id)+        let draft = model.recordDraft(for: id)         #expect(draft?.name == "Ada")         #expect(draft?.note == "Keeper")     }@@ -109,10 +172,10 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        model.updateRecordDraft(id: id) { $0.name = "Ada Vance" }         await model.commitEditing() -        let step = mock.committedCharacterEdits.first+        let step = mock.committedRecordEdits.first         #expect(step?.operations.count == 1)         guard case .update(let basis, let draft)? = step?.operations.first else {             Issue.record("Expected an update operation")@@ -131,7 +194,7 @@ struct WorkDetailCharacterTests {          await model.commitEditing() -        #expect(mock.committedCharacterEdits.isEmpty)+        #expect(mock.committedRecordEdits.isEmpty)     }      @Test("Creating a character stages a create")@@ -143,7 +206,7 @@ struct WorkDetailCharacterTests {         model.addCharacter(named: "Brede")         await model.commitEditing() -        guard case .create(let draft)? = mock.committedCharacterEdits.first?.operations.first else {+        guard case .create(let draft)? = mock.committedRecordEdits.first?.operations.first else {             Issue.record("Expected a create operation")             return         }@@ -157,16 +220,16 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.deleteCharacter(id: id)+        model.deleteRecord(id: id)         #expect(model.characterDrafts.isEmpty)          await model.commitEditing() -        guard case .delete(let basis)? = mock.committedCharacterEdits.first?.operations.first else {+        guard case .delete(let basis)? = mock.committedRecordEdits.first?.operations.first else {             Issue.record("Expected a delete operation")             return         }-        #expect(basis.characterID == id)+        #expect(basis.recordID == id)     }      @Test("Deleting a fact from a draft is a deletion the commit carries")@@ -182,7 +245,7 @@ struct WorkDetailCharacterTests {         model.deleteFact(dropped.identity, from: id)         await model.commitEditing() -        guard case .update(_, let draft)? = mock.committedCharacterEdits.first?.operations.first+        guard case .update(_, let draft)? = mock.committedRecordEdits.first?.operations.first         else {             Issue.record("Expected an update operation")             return@@ -203,14 +266,14 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.combineCharacter(source: source, into: target)+        model.combineRecord(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(mock.committedRecordEdits.isEmpty)         #expect(model.characterDrafts[source] != nil)     } @@ -225,11 +288,11 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.combineCharacter(source: source, into: target)-        model.updateCharacterDraft(id: target) { $0.note = "One person after all." }+        model.combineRecord(source: source, into: target)+        model.updateRecordDraft(id: target) { $0.note = "One person after all." }         await model.commitEditing() -        let operations = mock.committedCharacterEdits.first?.operations ?? []+        let operations = mock.committedRecordEdits.first?.operations ?? []         #expect(operations.count == 2)         guard case .combine = operations.first else {             Issue.record("Expected the combine first")@@ -252,8 +315,8 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        #expect(!model.canEditCharacter(id: torn))-        #expect(model.canEditCharacter(id: other))+        #expect(!model.canEditRecord(id: torn))+        #expect(model.canEditRecord(id: other))         #expect(model.combineTargets(for: other).map(\.id) == [])     } @@ -263,19 +326,19 @@ struct WorkDetailCharacterTests {     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")))+        mock.commitRecordEditsResult = .success(+            .refused(.basisMismatch(recordID: id, name: "Ada")))         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        model.updateRecordDraft(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`+    /// Q104: a tear can sync in while the editor sits open, so `commitRecordEdits`     /// 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.@@ -283,11 +346,11 @@ struct WorkDetailCharacterTests {     func workTornRefusalIsRendered() async {         let id = UUID()         let (model, mock) = makeSUT(characters: [Self.character(id: id)])-        mock.commitCharacterEditsResult = .success(.refused(.workTorn))+        mock.commitRecordEditsResult = .success(.refused(.workTorn))         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        model.updateRecordDraft(id: id) { $0.note = "Keeper" }         await model.commitEditing()          #expect(model.isEditing)@@ -298,16 +361,16 @@ struct WorkDetailCharacterTests {     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")))+        mock.commitRecordEditsResult = .success(+            .refused(.torn(recordID: id, name: "Ada")))         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.name = "Ada Vance" }+        model.updateRecordDraft(id: id) { $0.name = "Ada Vance" }         await model.commitEditing()          #expect(model.isEditing)-        #expect(model.characterRefusalRoutesToCheckLibrary)+        #expect(model.recordRefusalRoutesToCheckLibrary)     }      @Test("A committed character step leaves the editor and reloads")@@ -317,11 +380,11 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        model.updateRecordDraft(id: id) { $0.note = "Keeper" }         await model.commitEditing()          #expect(!model.isEditing)-        #expect(mock.committedCharacterEdits.count == 1)+        #expect(mock.committedRecordEdits.count == 1)     }      @Test("The character step runs even when no metadata changed")@@ -331,10 +394,10 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: id) { $0.note = "Keeper" }+        model.updateRecordDraft(id: id) { $0.note = "Keeper" }         await model.commitEditing() -        #expect(mock.committedCharacterEdits.count == 1)+        #expect(mock.committedRecordEdits.count == 1)         #expect(mock.updateWorkCallCount == 0)     } @@ -350,7 +413,7 @@ struct WorkDetailCharacterTests {         await model.load()         model.beginEditing() -        model.updateCharacterDraft(id: existing) { $0.note = "Keeper" }+        model.updateRecordDraft(id: existing) { $0.note = "Keeper" }         let created = model.addCharacter(named: "Brede")         model.draftTitle = "A corrected title"         mock.updateWorkResult = .failure(MockLibraryProvider.MockError.notConfigured)@@ -360,9 +423,9 @@ struct WorkDetailCharacterTests {         #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")+        #expect(model.recordDraft(for: existing)?.note == "Keeper")+        #expect(model.createdRecordIDs(of: .character) == [created])+        #expect(mock.committedRecordEdits.isEmpty, "nothing was written")     }      /// The screen used to sort the draft dictionary's keys by `uuidString`,@@ -378,10 +441,421 @@ struct WorkDetailCharacterTests {         let second = model.addCharacter(named: "Brede")         let third = model.addCharacter(named: "Cass") -        #expect(model.createdCharacterIDs == [first, second, third])+        #expect(model.createdRecordIDs(of: .character) == [first, second, third]) -        model.deleteCharacter(id: second)-        #expect(model.createdCharacterIDs == [first, third])+        model.deleteRecord(id: second)+        #expect(model.createdRecordIDs(of: .character) == [first, third])+    }+}++// MARK: - The record edit session over both kinds (`place-extraction` Reqs 3.2, 3.7)++/// One draft set with a kind on each draft, two cards reading filtered views of+/// it, and the conversion that moves a record between them — staged like every+/// other structural action and applied by the session's one Save (Decision 1).+@Suite("Work page places and conversion")+@MainActor+struct WorkDetailRecordSessionTests {++    private nonisolated static func character(+        id: UUID = UUID(), name: String = "Ada", note: String = "",+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        RecordSessionFixtures.character(+            id: id, name: name, note: note, aliases: aliases, facts: facts, isTorn: isTorn)+    }++    private nonisolated static func place(+        id: UUID = UUID(), name: String = "Kestrel Head", note: String = "",+        aliases: [String] = [], facts: [RecordFact] = [], isTorn: Bool = false+    ) -> WorkRecordPresentation {+        RecordSessionFixtures.place(+            id: id, name: name, note: note, aliases: aliases, facts: facts, isTorn: isTorn)+    }++    private func makeSUT(+        workID: UUID = UUID(),+        characters: [WorkRecordPresentation] = [],+        places: [WorkRecordPresentation] = []+    ) -> (WorkDetailModel, MockLibraryProvider) {+        RecordSessionFixtures.makeSUT(+            workID: workID, characters: characters, places: places)+    }++    // MARK: - One draft set, two cards (Req 4.1, design §Edit session)++    @Test("Every draft carries its kind, and each card reads its own filtered view")+    func draftsCarryTheirKind() async {+        let character = UUID(), place = UUID()+        let (model, _) = makeSUT(+            characters: [Self.character(id: character, name: "Ada")],+            places: [Self.place(id: place, name: "Kestrel Head")])++        await model.load()++        #expect(model.recordDrafts[character]?.kind == .character)+        #expect(model.recordDrafts[place]?.kind == .place)+        #expect(Set(model.characterDrafts.keys) == [character])+        #expect(Set(model.placeDrafts.keys) == [place])+        #expect(model.editLines(of: .character).map(\.id) == [character])+        #expect(model.editLines(of: .place).map(\.id) == [place])+    }++    @Test("The page shows the work's places as the repository ordered them")+    func showsPlacesInRepositoryOrder() async {+        let (model, _) = makeSUT(+            places: [Self.place(name: "Kestrel Head"), Self.place(name: "Bell Rock")])++        await model.load()++        #expect(model.places.map(\.name) == ["Kestrel Head", "Bell Rock"])+    }++    // MARK: - Hand-creation (Req 3.2)++    @Test("Adding a place stages a create under the place kind")+    func addingAPlaceStagesAPlaceCreate() async {+        let (model, mock) = makeSUT()+        await model.load()+        model.beginEditing()++        let id = model.addPlace(named: "Kestrel Head")+        #expect(model.placeDrafts[id]?.kind == .place)+        #expect(model.characterDrafts[id] == nil)+        #expect(model.createdRecordIDs(of: .place) == [id])+        #expect(model.createdRecordIDs(of: .character).isEmpty)++        await model.commitEditing()++        guard case .create(let draft)? = mock.committedRecordEdits.first?.operations.first else {+            Issue.record("Expected a create operation")+            return+        }+        #expect(draft.kind == .place)+        #expect(draft.name == "Kestrel Head")+    }++    @Test("A place is edited, deleted and combined through the same session calls")+    func placesUseTheSameSessionCalls() async {+        let source = UUID(), target = UUID()+        let (model, mock) = makeSUT(+            places: [+                Self.place(id: source, name: "The Head"),+                Self.place(id: target, name: "Kestrel Head"),+            ])+        await model.load()+        model.beginEditing()++        model.combineRecord(source: source, into: target)+        model.updateRecordDraft(id: target) { $0.note = "One headland after all." }+        await model.commitEditing()++        let operations = mock.committedRecordEdits.first?.operations ?? []+        #expect(operations.count == 2)+        guard case .combine(let combineSource, _) = operations.first else {+            Issue.record("Expected the combine first")+            return+        }+        #expect(combineSource.kind == .place)+        guard case .update(_, let draft) = operations.last else {+            Issue.record("Expected the update last")+            return+        }+        #expect(draft.kind == .place)+    }++    // MARK: - Conversion (Req 3.7, Decision 1, Q54)++    @Test("Converting flips the draft's kind, moves its line, and stages one convert")+    func convertingStagesAConvert() async {+        let id = UUID()+        let (model, mock) = makeSUT(+            characters: [Self.character(id: id, name: "Kestrel Head", note: "a headland")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)++        // The line moves cards at once: the reader sees the correction before it+        // is written, which is what makes Save the only write.+        #expect(model.recordDraft(for: id)?.kind == .place)+        #expect(model.characterDrafts[id] == nil)+        #expect(model.placeDrafts[id] != nil)+        #expect(model.editLines(of: .character).isEmpty)+        #expect(model.editLines(of: .place).map(\.id) == [id])+        #expect(model.isConverted(id: id))++        await model.commitEditing()++        let operations = mock.committedRecordEdits.first?.operations ?? []+        #expect(operations.count == 1)+        guard case .convert(let basis, let kind, let draft) = operations.first else {+            Issue.record("Expected a convert operation")+            return+        }+        #expect(basis.kind == .character)+        #expect(basis.recordID == id)+        #expect(kind == .place)+        #expect(draft.name == "Kestrel Head")+        #expect(draft.note == "a headland")+    }++    /// Q54: the convert mints a UUID the session cannot know, so an edit made+    /// after it rides on the convert's own draft rather than becoming a second+    /// operation naming a record that does not exist yet.+    @Test("An edit after a convert rides on the convert and emits no update")+    func convertThenEditRidesOnTheConvert() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Kestrel Head")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)+        model.updateRecordDraft(id: id) { $0.note = "The light stands here." }+        await model.commitEditing()++        let operations = mock.committedRecordEdits.first?.operations ?? []+        #expect(operations.count == 1)+        guard case .convert(_, _, let draft) = operations.first else {+            Issue.record("Expected a single convert operation")+            return+        }+        #expect(draft.note == "The light stands here.")+    }++    @Test("A convert the reader takes back stages nothing at all")+    func unconvertingRemovesTheStagedOperation() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Kestrel Head")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)+        model.convertRecord(id: id)++        #expect(model.recordDraft(for: id)?.kind == .character)+        #expect(!model.isConverted(id: id))+        #expect(model.editLines(of: .character).map(\.id) == [id])++        await model.commitEditing()++        #expect(mock.committedRecordEdits.isEmpty, "an untouched record stages nothing")+    }++    @Test("Converting then deleting is a plain delete")+    func convertThenDeleteIsAPlainDelete() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Kestrel Head")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)+        model.deleteRecord(id: id)+        await model.commitEditing()++        let operations = mock.committedRecordEdits.first?.operations ?? []+        #expect(operations.count == 1)+        guard case .delete(let basis) = operations.first else {+            Issue.record("Expected a delete operation")+            return+        }+        #expect(basis.kind == .character)+        #expect(basis.recordID == id)+    }++    @Test("Creating then converting is a create under the other kind")+    func createThenConvertIsACreate() async {+        let (model, mock) = makeSUT()+        await model.load()+        model.beginEditing()++        let id = model.addCharacter(named: "Kestrel Head")+        model.convertRecord(id: id)++        #expect(model.createdRecordIDs(of: .place) == [id])+        #expect(model.createdRecordIDs(of: .character).isEmpty)++        await model.commitEditing()++        let operations = mock.committedRecordEdits.first?.operations ?? []+        #expect(operations.count == 1)+        guard case .create(let draft) = operations.first else {+            Issue.record("Expected a create operation")+            return+        }+        #expect(draft.kind == .place)+        #expect(draft.name == "Kestrel Head")+    }++    /// Q54: a converted record's new UUID does not exist until the commit, and a+    /// record created in the session has none either, so neither may be named by+    /// a combine — on either side.+    @Test("Combine offers neither a converted record nor one created in the session")+    func combineTargetsExcludeConvertedAndCreated() async {+        let ada = UUID(), brede = UUID(), cass = UUID()+        let (model, _) = makeSUT(+            characters: [+                Self.character(id: ada, name: "Ada"),+                Self.character(id: brede, name: "Brede"),+                Self.character(id: cass, name: "Cass"),+            ])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: brede)+        let created = model.addCharacter(named: "Dane")++        #expect(model.combineTargets(for: ada).map(\.id) == [cass])+        #expect(model.combineTargets(for: brede).isEmpty, "a converted record is not a source")+        #expect(model.combineTargets(for: created).isEmpty, "a created record is not a source")+    }++    @Test("Combine never crosses kinds")+    func combineTargetsAreOfTheDraftsOwnKind() async {+        let ada = UUID(), head = UUID(), rock = UUID()+        let (model, _) = makeSUT(+            characters: [Self.character(id: ada, name: "Ada")],+            places: [+                Self.place(id: head, name: "Kestrel Head"),+                Self.place(id: rock, name: "Bell Rock"),+            ])+        await model.load()+        model.beginEditing()++        #expect(model.combineTargets(for: ada).isEmpty)+        #expect(model.combineTargets(for: head).map(\.id) == [rock])+    }++    @Test("A torn place is read-only, and cannot be converted")+    func tornPlaceIsReadOnly() async {+        let torn = UUID()+        let (model, mock) = makeSUT(+            places: [Self.place(id: torn, name: "Kestrel Head", isTorn: true)])+        await model.load()+        model.beginEditing()++        #expect(!model.canEditRecord(id: torn))+        model.convertRecord(id: torn)+        #expect(!model.isConverted(id: torn))+        #expect(model.recordDraft(for: torn)?.kind == .place)++        await model.commitEditing()+        #expect(mock.committedRecordEdits.isEmpty)+    }++    // MARK: - The commit's order (design §Edit session, Q55)++    /// URL, then metadata, then **one** record step, then `onMutation()`, then+    /// one `load()`. The mutation call is what reaches the coordinator's+    /// `reconcile()`, so a bundle targeting a deleted or converted record is+    /// discarded immediately rather than at the next arrival.+    @Test("A committed record step notifies the app before the one reload")+    func recordStepNotifiesBeforeReloading() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        model.updateRecordDraft(id: id) { $0.note = "Keeper" }+        await model.commitEditing()++        #expect(+            RecordSessionFixtures.commitLog(mock)+                == ["workDetail", "commitRecordEdits", "mutation", "workDetail"])+        #expect(mock.updateWorkCallCount == 0, "no metadata changed")+    }++    @Test("A metadata change and a record step reload the screen once between them")+    func metadataAndRecordStepReloadOnce() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id)])+        await model.load()+        model.beginEditing()++        model.draftTitle = "A corrected title"+        model.updateRecordDraft(id: id) { $0.note = "Keeper" }+        await model.commitEditing()++        #expect(mock.updateWorkCallCount == 1)+        #expect(mock.committedRecordEdits.count == 1)+        #expect(+            mock.workDetailCallCount == 2,+            "the metadata save skips its reload when a record step follows")+        #expect(RecordSessionFixtures.commitLog(mock).last == "workDetail")+    }++    /// The `throw` path used to restore from the snapshot, which re-adopted the+    /// stored records and threw the staged session away. A staged conversion is+    /// the same draft state as any other and has to survive it.+    @Test("A thrown metadata save keeps the staged record session, conversion included")+    func thrownSaveKeepsTheStagedRecordSession() async {+        let existing = UUID()+        let (model, mock) = makeSUT(+            characters: [Self.character(id: existing, name: "Kestrel Head")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: existing)+        let created = model.addPlace(named: "Bell Rock")+        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.isConverted(id: existing))+        #expect(model.recordDraft(for: existing)?.kind == .place)+        #expect(model.createdRecordIDs(of: .place) == [created])+        #expect(mock.committedRecordEdits.isEmpty, "nothing was written")+    }++    /// `refusedStepKeepsTheEditorOpen` covers the refusal of an `.update`. A+    /// `.convert` is the operation with the most session state riding on it: the+    /// draft's kind has already flipped and the line has already moved cards, so+    /// a refusal that dropped the staged convert — or reloaded over it — would+    /// put the line back on the card the reader had just corrected, with nothing+    /// to say the correction had not been written.+    @Test("A refused convert keeps the editor open with the conversion still staged")+    func refusedConvertKeepsTheStagedConversion() async {+        let id = UUID()+        let (model, mock) = makeSUT(+            characters: [Self.character(id: id, name: "Kestrel Head")])+        mock.commitRecordEditsResult = .success(+            .refused(.basisMismatch(recordID: id, name: "Kestrel Head")))+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)+        await model.commitEditing()++        #expect(model.isEditing, "a refused step keeps the reader in the editor")+        #expect(model.errorMessage?.contains("Kestrel Head") == true)+        #expect(model.isConverted(id: id), "the convert is still staged")+        #expect(+            model.recordDraft(for: id)?.kind == .place,+            "and the draft still holds the destination kind")+        #expect(model.editLines(of: .place).map(\.id) == [id])+        #expect(model.editLines(of: .character).isEmpty)+        #expect(+            mock.workDetailCallCount == 1,+            "no reload: a `load()` here would reassign every draft over the refusal")+    }++    @Test("A discard puts the converted record back on its own card")+    func cancellingDiscardsTheConversion() async {+        let id = UUID()+        let (model, mock) = makeSUT(characters: [Self.character(id: id, name: "Kestrel Head")])+        await model.load()+        model.beginEditing()++        model.convertRecord(id: id)+        model.cancelEditing()++        #expect(!model.isConverted(id: id))+        #expect(model.recordDraft(for: id)?.kind == .character)+        #expect(model.editLines(of: .character).map(\.id) == [id])+        #expect(mock.committedRecordEdits.isEmpty)     } } @@ -392,7 +866,7 @@ struct WorkDetailCharacterTests { struct EntryDetailCitingCharactersTests {      private nonisolated static func detail(-        entryID: UUID, citing: [EntryCitingCharacter]+        entryID: UUID, citing: [EntryCitingRecord], citingPlaces: [EntryCitingRecord] = []     ) -> EntryTeachingDetail {         EntryTeachingDetail(             entry: TestFixtures.makeEntry(id: entryID),@@ -403,7 +877,8 @@ struct EntryDetailCitingCharactersTests {             assignmentSettlement: .unsettled(reason: "no pattern"),             availableActions: [],             unresolvedCandidateTitle: nil,-            citingCharacters: citing)+            citingCharacters: citing,+            citingPlaces: citingPlaces)     }      @Test("An entry no character cites shows no section")@@ -425,7 +900,7 @@ struct EntryDetailCitingCharactersTests {         mock.entryTeachingDetailResult = .success(             Self.detail(                 entryID: entryID,-                citing: [EntryCitingCharacter(id: UUID(), name: "Ada", factCount: 2)]))+                citing: [EntryCitingRecord(id: UUID(), name: "Ada", factCount: 2)]))         let model = EntryDetailModel(entryID: entryID, library: mock, onMutation: {})          await model.load()@@ -433,6 +908,43 @@ struct EntryDetailCitingCharactersTests {         #expect(model.citingCharacters.map(\.name) == ["Ada"])         #expect(model.citingCharacters.first?.factCount == 2)     }++    /// `place-extraction` Req 4.4: a section of its own, off the same locked+    /// read. A second call would describe a different moment, and the two+    /// sections would be able to disagree about the note they are both about.+    @Test("The places citing an entry come off the same read, separately from the characters")+    func citingPlacesComeOffTheSameRead() async {+        let mock = MockLibraryProvider()+        let entryID = UUID()+        mock.entryTeachingDetailResult = .success(+            Self.detail(+                entryID: entryID,+                citing: [EntryCitingRecord(id: UUID(), name: "Ada", factCount: 2)],+                citingPlaces: [+                    EntryCitingRecord(id: UUID(), name: "Bell Rock", factCount: 1),+                    EntryCitingRecord(id: UUID(), name: "Kestrel Head", factCount: 3),+                ]))+        let model = EntryDetailModel(entryID: entryID, library: mock, onMutation: {})++        await model.load()++        #expect(model.citingPlaces.map(\.name) == ["Bell Rock", "Kestrel Head"])+        #expect(model.citingPlaces.last?.factCount == 3)+        #expect(model.citingCharacters.map(\.name) == ["Ada"])+        #expect(mock.entryTeachingDetailCallCount == 1, "one read, not two")+    }++    @Test("An entry no place cites shows no place section")+    func noPlaceCitationsShowsNothing() 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.citingPlaces.isEmpty)+    } }  // MARK: - The duplicate-resolution character arm (Req 6.5, Q102)@@ -442,7 +954,7 @@ struct EntryDetailCitingCharactersTests { struct DuplicateResolutionCharacterArmTests {      private nonisolated static func contract(-        setKey: DuplicateSetKey, variants: [CharacterVariantChoice]+        setKey: DuplicateSetKey, variants: [RecordVariantChoice]     ) -> DuplicateResolutionContract {         .character(             setKey: setKey, variants: variants, differingFields: [.note],@@ -454,10 +966,10 @@ struct DuplicateResolutionCharacterArmTests {         let id = UUID()         let setKey = DuplicateSetKey(recordType: .character, memberIDs: [id])         let variants = [-            CharacterVariantChoice(+            RecordVariantChoice(                 id: VariantID(rawValue: "a"), name: "Ada", note: "Keeper", aliases: [],                 factCount: 3, firstCapturedAt: Date(timeIntervalSince1970: 0)),-            CharacterVariantChoice(+            RecordVariantChoice(                 id: VariantID(rawValue: "b"), name: "Ada", note: "Lightkeeper",                 aliases: ["Nightjar"], factCount: 2,                 firstCapturedAt: Date(timeIntervalSince1970: 10)),@@ -516,6 +1028,73 @@ struct DuplicateResolutionCharacterArmTests {         #expect(plan.elsewhere.count == 1)         #expect(plan.elsewhere.first?.text.contains("character") == true)     }++    // MARK: - The place arm (`place-extraction` Req 5.3, design §Convergence parity)++    private nonisolated static func tornPlaceItem() -> DuplicateReviewItem {+        let id = UUID()+        return DuplicateReviewItem(+            key: DuplicateSetKey(recordType: .place, memberIDs: [id]),+            route: .sheet,+            memberIDs: [id],+            variantCount: 2,+            isTorn: true)+    }++    @Test("A place set's variants reach the sheet")+    func placeVariantsAreExposed() async {+        let id = UUID()+        let setKey = DuplicateSetKey(recordType: .place, memberIDs: [id])+        let variants = [+            RecordVariantChoice(+                id: VariantID(rawValue: "a"), name: "Kestrel Head", note: "the headland",+                aliases: [], factCount: 3, firstCapturedAt: Date(timeIntervalSince1970: 0)),+            RecordVariantChoice(+                id: VariantID(rawValue: "b"), name: "Kestrel Head", note: "the light's headland",+                aliases: ["The Head"], factCount: 2,+                firstCapturedAt: Date(timeIntervalSince1970: 10)),+        ]+        let mock = MockLibraryProvider()+        mock.projectDuplicateResolutionResult = .success(+            .place(+                setKey: setKey, variants: variants, differingFields: [.note],+                preselected: variants[0].id))+        let model = DuplicateResolutionModel(setKey: setKey, library: mock, onMutation: {})++        await model.load()++        #expect(model.placeVariants.map(\.note) == ["the headland", "the light's headland"])+        #expect(model.characterVariants.isEmpty)+        #expect(model.entryVariants.isEmpty)+        #expect(model.workVariants.isEmpty)+        #expect(model.selectedVariantID == VariantID(rawValue: "a"))+    }++    @Test("A place set is labelled as a place, not as an entry")+    func placeSetIsLabelledCorrectly() async {+        let mock = MockLibraryProvider()+        mock.duplicateWorkload = (+            DuplicateWorkload(reviewItems: [Self.tornPlaceItem()], deferredItems: []))+        let model = LibraryDiagnosticsModel(library: mock, onReteach: { _ in })++        await model.load()++        let text = model.rows.map(\.problem).joined(separator: " ")+        #expect(text.contains("place"))+        #expect(!text.contains("entry"))+    }++    @Test("A place set is not mis-bucketed into Recent's entry filter")+    func placeSetIsNotAnEntryItem() {+        let plan = RecentDuplicatePlan(+            workload: DuplicateWorkload(reviewItems: [Self.tornPlaceItem()], deferredItems: []),+            conflictCount: 0)++        #expect(plan.entryItems.isEmpty)+        #expect(plan.elsewhere.count == 1)+        #expect(plan.elsewhere.first?.text.contains("place") == true)+        #expect(plan.elsewhere.first?.text.contains("work") == false)+    } }  // MARK: - The edit step against a real store (Q97, Q108)@@ -562,8 +1141,8 @@ struct WorkDetailCharacterCommitTests {      private nonisolated static func fact(         _ statement: String, _ quote: String, key: String-    ) -> CharacterFact {-        CharacterFact(statement: statement, quote: quote, nameKey: key, source: .genericNotes)+    ) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: key, source: .genericNotes)     }      /// One work with two characters, each carrying a fact of its own.@@ -572,13 +1151,15 @@ struct WorkDetailCharacterCommitTests {     ) 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(+        let outcome = try await fixture.repository.commitRecordEdits(             workID: work.id,             operations: [-                .create(CharacterDraft(+                .create(RecordDraft(+                    kind: .character,                     name: "Ada", note: "the keeper", aliases: [],                     facts: [Self.fact("Ada keeps the light.", "Ada keeps the light", key: "ada")])),-                .create(CharacterDraft(+                .create(RecordDraft(+                    kind: .character,                     name: "Vance", note: "the rower", aliases: [],                     facts: [Self.fact("Vance rows out.", "Vance rows out", key: "vance")])),             ])@@ -599,8 +1180,8 @@ struct WorkDetailCharacterCommitTests {         #expect(model.characters.count == 2)         model.beginEditing() -        model.combineCharacter(source: rower, into: keeper)-        model.updateCharacterDraft(id: keeper) {+        model.combineRecord(source: rower, into: keeper)+        model.updateRecordDraft(id: keeper) {             $0.name = "Adelaide"             $0.note = "One person after all."         }@@ -633,14 +1214,15 @@ struct WorkDetailCharacterCommitTests {         await model.load()         let basis = try #require(model.characters.first { $0.id == keeper }?.editBasis)         model.beginEditing()-        model.updateCharacterDraft(id: keeper) { $0.note = "Mine." }+        model.updateRecordDraft(id: keeper) { $0.note = "Mine." }          // Another device writes to the same character while the editor sits open.-        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workID,             operations: [.update(                 basis: basis,-                draft: CharacterDraft(+                draft: RecordDraft(+                    kind: .character,                     name: "Ada", note: "Theirs.", aliases: [],                     facts: [                         Self.fact("Ada keeps the light.", "Ada keeps the light", key: "ada")
Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Modified +135 / -0
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 6e4a433..f98b5c4 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -1016,6 +1016,141 @@ final class AccessibilityJourneyUITests: XCTestCase {         chapter.tap()     } +    /// `place-extraction` Reqs 2.2 and 4.1 at the largest Dynamic Type size:+    /// the review list's kind control, and the Places section the row it keeps+    /// lands in.+    ///+    /// The kind control is the risk, for the reason the status capsules are:+    /// `ConstellationSegmentedControl` lays its segments across a row and falls+    /// back to a stacked layout through `ViewThatFits` when they no longer fit,+    /// and at `accessibility5` on a phone they do not — so this case walks the+    /// stacked arm inside a sheet rather than in a `Form` row.+    ///+    /// Stub-driven like every extraction journey: `ASTERISM_UI_TEST_EXTRACTION`+    /// substitutes the scripted client, so nothing here waits on a model.+    @MainActor+    func testThePlacesSectionAndReviewKindControlAtLargestDynamicType() {+        app.launchEnvironment["ASTERISM_UI_TEST_EXTRACTION"] = "canned"+        launchSeeded(+            scenario: "seeded-characters",+            extraArguments: [+                "-UIPreferredContentSizeCategoryName", "UICTContentSizeCategoryAccessibilityXXXL"+            ])++        XCTAssertTrue(+            app.collectionViews["recent-list"].waitForExistence(timeout: 60),+            "The library opens")+        let works = app.tabControl(.works)+        XCTAssertTrue(works.waitForExistence(timeout: 30), "The Works tab is reachable")+        works.tap()+        let workRow = app.elements(withIdentifierPrefix: "work-row-").firstMatch+        XCTAssertTrue(workRow.waitForExistence(timeout: 20), "The seeded work is listed")+        scrollToElement(workRow, attempts: 8)+        workRow.tap()++        XCTAssertTrue(+            app.anyElement("work-detail-character-proposals").waitForExistence(timeout: 60),+            "The sweep's proposals raise the indicator")+        scrollUntilTappableAndTap(+            app.buttons["work-detail-review-characters-button"], in: app,+            "The indicator offers the review list at this size")+        XCTAssertTrue(+            app.anyElement("character-review-banner").waitForExistence(timeout: 20),+            "The review list opens")++        // Req 10.1's bar, on the new control: an identifier, a label, and a+        // 44 pt target at the size where a row of segments stops fitting.+        for identifier in [+            "character-review-kind-place:kestrel head-character",+            "character-review-kind-place:kestrel head-place",+        ] {+            let segment = app.buttons[identifier]+            scrollUntilPresent(segment, in: app, "The review list offers \(identifier)")+            scrollToElement(segment, attempts: 8)+            assertContentControl(segment, named: "\(identifier) at largest Dynamic Type")+        }++        // A second place, taken through the control the segments above were+        // measured on. Two stored places are what puts Combine in the editor's+        // footer below, so the three worded actions this case is about are all+        // three offered.+        tapReviewControl(+            "character-review-kind-character:ada-place",+            "The character row offers the kind control at this size")+        tapReviewControl(+            "character-review-keep-character:ada",+            "The reclassified row can be kept at largest Dynamic Type")++        tapReviewControl(+            "character-review-keep-place:kestrel head",+            "The place can be kept at largest Dynamic Type")+        let done = app.buttons["character-review-done"]+        XCTAssertTrue(done.waitForExistence(timeout: 20), "The review list closes")+        done.tap()++        // Req 4.1: the Places section draws at this size, and its pill is still+        // an operable control rather than a clipped one.+        let placePill = app.anyElement("work-detail-place")+        scrollUntilPresent(+            placePill, in: app, "The Places section draws at largest Dynamic Type")+        scrollToElement(placePill, attempts: 8)+        assertContentControl(placePill, named: "The place pill at largest Dynamic Type")++        // Req 3.7 at this size: the record editor's Combine, Convert and Delete.+        // `CharacterEditorView.structuralActions` wraps them in a `FlowLayout`+        // precisely because three worded buttons do not fit one phone row at the+        // accessibility sizes — so this is the size at which "present and+        // hittable" says anything about them, and the sheet is where the+        // wrapping happens rather than in a `Form` row.+        scrollUntilTappableAndTap(+            app.buttons["work-detail-edit-button"], in: app,+            "The page offers its editor at largest Dynamic Type")+        // The X, not the title field: the editor keeps the scroll offset the+        // page was left at — and this journey left it at the Places section —+        // so the first row is above the fold and a lazy `List` does not publish+        // it. The toolbar item is always in the tree (the series journey's+        // note, for the same reason).+        XCTAssertTrue(+            app.buttons["work-detail-edit-cancel-button"].waitForExistence(timeout: 20),+            "The editor is open")++        // Back to the top of the editor, because `scrollUntilTappableAndTap`+        // only ever scrolls downwards and the Places card is below whatever row+        // the preserved offset landed on.+        for _ in 0..<8 { app.swipeDown() }++        let placeLine = app.buttons+            .matching(NSPredicate(format: "identifier BEGINSWITH %@", "work-detail-place-line-"))+            .firstMatch+        openEditorLine(+            placeLine, expecting: "character-editor", in: app,+            "A place's line opens its editor at largest Dynamic Type")++        for identifier in [+            "work-detail-character-combine",+            "work-detail-character-convert",+            "work-detail-character-delete",+        ] {+            let action = app.buttons[identifier]+            scrollUntilPresent(+                action, in: app, "The editor offers \(identifier) at this size")+            scrollToElement(action, attempts: 8)+            assertContentControl(action, named: "\(identifier) at largest Dynamic Type")+        }+    }++    /// Taps a review-list control, walking back to the top of the list first.+    ///+    /// `scrollUntilTappableAndTap` only ever goes one way, and a decision taken+    /// half-way down leaves the list wherever it was — so a row above the+    /// current position is unreachable without this. The same walk+    /// `CharacterExtractionUITests` makes, and at `accessibility5` there is even+    /// less of the list on screen at once.+    private func tapReviewControl(_ identifier: String, _ message: String) {+        for _ in 0..<6 where !app.anyElement(identifier).isHittable { app.swipeDown() }+        scrollUntilTappableAndTap(app.anyElement(identifier), in: app, message)+    }+     private func launchSeeded(scenario: String = "seeded-m1", extraArguments: [String] = []) {         app.launchEnvironment["ASTERISM_UI_TEST_SCENARIO"] = scenario         app.launchEnvironment["ASTERISM_UI_TEST_RUN_ID"] = UUID().uuidString
Asterism/AsterismUITests/CharacterExtractionUITests.swift Modified +361 / -26
diff --git a/Asterism/AsterismUITests/CharacterExtractionUITests.swift b/Asterism/AsterismUITests/CharacterExtractionUITests.swiftindex 96298c6..c915dc0 100644--- a/Asterism/AsterismUITests/CharacterExtractionUITests.swift+++ b/Asterism/AsterismUITests/CharacterExtractionUITests.swift@@ -13,10 +13,30 @@ import XCTest /// /// 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.+/// Brede, and, since `place-extraction`, the headland Kestrel Head and the+/// vessel Selkie.+///+/// Four rows come out of the canned result over that fixture: Ada and Brede as+/// characters, Kestrel Head as a place, and Selkie — returned under both kinds+/// with no existing record of either — as Req 1.5's union row, displayed as a+/// character (Q20) and disclosing that a skip decides both (Q38). final class CharacterExtractionUITests: XCTestCase {     let app = XCUIApplication() +    /// The review rows' ids: `ProposalKey`'s string form, `<kind>:<name key>`.+    /// Every `character-review-*` identifier is built from one, so a journey+    /// that names a row names its kind too.+    private enum Row {+        static let ada = "character:ada"+        static let brede = "character:brede"+        static let selkie = "character:selkie"+        static let kestrelHead = "place:kestrel head"+        /// What the union row's place copy becomes once the character Selkie is+        /// kept: the character copy dedupes away and the place copy stands+        /// alone, carrying Q41's cross-kind hint.+        static let selkiePlace = "place:selkie"+    }+     override func setUp() {         continueAfterFailure = false         XCUIDevice.shared.orientation = .portrait@@ -61,6 +81,47 @@ final class CharacterExtractionUITests: XCTestCase {             app.anyElement("character-review-banner"), "The review list is dismissed", timeout: 15)     } +    /// Walks the open review list to find one of its elements, and reports+    /// whether it was ever on screen.+    ///+    /// **`waitForExistence` is not enough here.** Four rows no longer fit one+    /// phone screen, and a `Form` row below the fold is not in the accessibility+    /// tree at all — so a bare existence check on the third row asserts the fold+    /// rather than the list (`docs/agent-notes/testing.md`). The walk goes back+    /// to the top first, because a decision taken half-way down leaves the list+    /// wherever it was and `swipeUp` only ever goes one way.+    private func reviewListShows(_ identifier: String) -> Bool {+        for _ in 0..<6 where !app.anyElement(identifier).exists { app.swipeDown() }+        for _ in 0..<10 {+            if app.anyElement(identifier).exists { return true }+            app.swipeUp()+        }+        return false+    }++    /// The labels of the review-list elements named, gathered in one walk down+    /// the list — one pass rather than one per identifier, because a row that+    /// has scrolled back off is out of the tree again.+    private func reviewListLabels(_ identifiers: [String]) -> [String: String] {+        var seen: [String: String] = [:]+        for _ in 0..<6 where seen.isEmpty { app.swipeDown() }+        for _ in 0..<10 {+            for identifier in identifiers where seen[identifier] == nil {+                let element = app.anyElement(identifier)+                if element.exists { seen[identifier] = element.label }+            }+            if seen.count == identifiers.count { return seen }+            app.swipeUp()+        }+        return seen+    }++    /// Taps a review row's control, scrolling the list until it is reachable.+    private func tapReviewControl(_ identifier: String, _ message: String) {+        for _ in 0..<6 where !app.anyElement(identifier).isHittable { app.swipeDown() }+        scrollUntilTappableAndTap(app.anyElement(identifier), in: app, message)+    }+     /// The proposed-fact toggle whose statement contains `text`.     ///     /// Deliberately not addressed by its identifier alone: a fact's id is its@@ -95,14 +156,15 @@ final class CharacterExtractionUITests: XCTestCase {         pill.coordinate(withNormalizedOffset: CGVector(dx: 0.08, dy: 0.5)).tap()     } -    /// The cast pill for one name. The pills all carry the same identifier, so-    /// the name they are labelled with is what tells them apart.-    private func pill(named name: String) -> XCUIElement {+    /// The cast pill for one name. The pills of one collection all carry the+    /// same identifier, so the name they are labelled with is what tells them+    /// apart.+    private func pill(named name: String, kind: String = "character") -> XCUIElement {         app.descendants(matching: .any)             .matching(                 NSPredicate(                     format: "identifier == %@ AND label BEGINSWITH %@",-                    "work-detail-character", name))+                    "work-detail-\(kind)", name))             .firstMatch     } @@ -118,12 +180,12 @@ final class CharacterExtractionUITests: XCTestCase {     /// with Brede, whose one quote grounds in the generic notes *and* in the     /// chapter note and so scores two buckets (`f(1) + f(1) = 2`) against Ada's     /// one bucket of two generic facts (`log2 3 ≈ 1.585`).-    private func editLine(named name: String) -> XCUIElement {+    private func editLine(named name: String, kind: String = "character") -> XCUIElement {         app.buttons             .matching(                 NSPredicate(                     format: "identifier BEGINSWITH %@ AND label BEGINSWITH %@",-                    "work-detail-character-line-", name))+                    "work-detail-\(kind)-line-", name))             .firstMatch     } @@ -164,13 +226,277 @@ final class CharacterExtractionUITests: XCTestCase {      func testTheSweepRaisesAnIndicatorAndTheListPresentsBothProposals() {         openWorkDetail()++        // `place-extraction` Req 2.1: one indicator for both kinds, and its+        // noun says so — it opens a list with places in it.+        //+        // Every element of the `Label` carries the identifier, the glyph+        // included, so the sentence is looked for across all of them rather+        // than on whichever one the query returns first.+        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The sweep's proposals raise the indicator", timeout: 60)+        let indicators = app.descendants(matching: .any)+            .matching(identifier: "work-detail-character-proposals")+        let labels = (0..<indicators.count).map { indicators.element(boundBy: $0).label }+        XCTAssertTrue(+            labels.contains { $0.contains("suggestions from your notes") },+            "The indicator counts both kinds — was \(labels)")+        XCTAssertFalse(+            labels.contains { $0.contains("character suggestion") },+            "The indicator no longer claims the suggestions are all characters")+         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")+        let shown = reviewListLabels([+            "character-review-keep-\(Row.ada)",+            "character-review-keep-\(Row.brede)",+            // Decision 5: the compound name split, with the second half offered+            // as a strikeable alias rather than installed silently (Q92).+            "character-review-alias-Nightjar",+            // Req 2.1: the place row is here, in the same list, labelled with+            // the collection it would join.+            "character-review-keep-\(Row.kestrelHead)",+            "character-review-kind-caption-\(Row.kestrelHead)",+            // Req 2.1/Q38: the union row says its label is only half the story,+            // because skipping it decides both kinds.+            "character-review-dual-\(Row.selkie)",+        ])++        XCTAssertNotNil(shown["character-review-keep-\(Row.ada)"], "Ada is proposed")+        XCTAssertNotNil(shown["character-review-keep-\(Row.brede)"], "Brede is proposed")+        XCTAssertNotNil(shown["character-review-alias-Nightjar"], "The split alias is shown")+        XCTAssertNotNil(+            shown["character-review-keep-\(Row.kestrelHead)"],+            "The place is proposed in the same list")+        XCTAssertEqual(+            shown["character-review-kind-caption-\(Row.kestrelHead)"], "Place",+            "The place row says which collection it would join")+        XCTAssertNotNil(+            shown["character-review-dual-\(Row.selkie)"],+            "The union row discloses that it was suggested under both kinds")+    }++    // MARK: - `place-extraction` Reqs 2.1, 4.1: a kept place++    func testKeepingAPlaceWritesItIntoThePlacesSection() {+        openWorkDetail()+        openReview()++        tapReviewControl("character-review-keep-\(Row.kestrelHead)", "The place can be kept")+        waitUntilGone(+            app.anyElement("character-review-keep-\(Row.kestrelHead)"),+            "The decided row leaves the list", timeout: 15)++        closeReview()++        // Req 4.1: its own section, composed as the cast is — the quotes stay+        // folded until the pill is asked. It sits below the Characters section,+        // so it is scrolled to rather than waited for where the cast is.+        let place = app.anyElement("work-detail-place")+        scrollUntilPresent(place, in: app, "The kept place appears on the page")+        XCTAssertTrue(place.label.hasPrefix("Kestrel Head"), "was \(place.label)")+        XCTAssertFalse(+            app.anyElement("work-detail-character").exists,+            "Keeping a place writes no character")++        openPill(pill(named: "Kestrel Head", kind: "place"))+        scrollUntilPresent(+            app.staticTexts["The lighthouse stands at Kestrel Head."], in: app,+            "The place's fact was written")+    }++    // MARK: - Req 2.2: reclassification, then keeping under the new kind++    /// The reader's one-tap correction of the model's kind. The row keeps its+    /// id (Q28: identity is the *assembled* kind), so the Keep beside the+    /// picker is the same control it was — what changes is the table it writes.+    func testReclassifyingACharacterRowKeepsItAsAPlace() {+        openWorkDetail()+        openReview()++        scrollUntilTappableAndTap(+            app.buttons["character-review-kind-\(Row.ada)-place"], in: app,+            "The candidate offers the kind control")++        tapReviewControl("character-review-keep-\(Row.ada)", "The row is still here")+        waitUntilGone(+            app.anyElement("character-review-keep-\(Row.ada)"),+            "The decided row leaves the list", timeout: 15)++        closeReview()++        let place = app.anyElement("work-detail-place")+        scrollUntilPresent(place, in: app, "The reclassified row landed as a place")+        XCTAssertTrue(place.label.hasPrefix("Ada"), "was \(place.label)")+        XCTAssertFalse(+            app.anyElement("work-detail-character").exists,+            "Nothing was written into the cast")+    }++    // MARK: - Q41: the cross-kind hint++    /// The model files places as characters often enough that the reader is+    /// told when the other collection already answers to a name.+    ///+    /// Selkie is the fixture's dual-kind name. Keeping it as a character leaves+    /// the character copy of the next pass with nothing new to add — so it+    /// dedupes away and the place copy stands alone, carrying the hint.+    func testAKeptCharactersNameHintsOnThePlaceRowThatFollowsIt() {+        openWorkDetail()+        openReview()++        tapReviewControl("character-review-keep-\(Row.selkie)", "The union row can be kept")+        closeReview()+        waitFor(+            app.anyElement("work-detail-character"), "The kept character appears", timeout: 30)++        // Req 1.11: the manual pass ignores coverage, so the same source is read+        // again — this time against a library that holds the character.+        scrollUntilTappableAndTap(+            app.buttons["work-detail-extract-characters"], in: app,+            "The manual trigger is offered")+        waitFor(+            app.anyElement("work-detail-extract-ready"),+            "The manual pass reports what it found", timeout: 60)++        openReview()+        XCTAssertTrue(+            reviewListShows("character-review-hint-\(Row.selkiePlace)"),+            "The place row says a character of this name is already kept")+    }++    // MARK: - Req 3.7: conversion in the editor++    /// Decision 1: the conversion is staged, so the line moves cards the moment+    /// the reader taps and the session's one Save is what writes it.+    func testConvertingInTheEditorMovesTheLineAndSurvivesSave() {+        openWorkDetail()+        openReview()+        tapReviewControl("character-review-keep-\(Row.ada)", "Ada can be kept")+        closeReview()+        waitFor(app.anyElement("work-detail-character"), "The kept character appears", timeout: 30)++        waitFor(app.buttons["work-detail-edit-button"], "The page offers its editor").tap()+        waitFor(app.anyElement("work-detail-title-field"), "The editor is open")++        openEditorLine(+            editLine(named: "Ada"), expecting: "character-editor", in: app,+            "Ada's line opens her editor")+        scrollUntilTappableAndTap(+            app.buttons["work-detail-character-convert"], in: app,+            "The editor offers the conversion")+        waitUntilGone(+            app.anyElement("character-editor"),+            "The conversion closes the editor it was taken from", timeout: 20)++        // The line has moved cards, before anything is written.+        scrollUntilPresent(+            editLine(named: "Ada", kind: "place"), in: app,+            "The converted line is on the Places card")+        XCTAssertFalse(+            editLine(named: "Ada").exists, "and no longer on the Characters card")++        waitFor(app.buttons["work-detail-save-button"], "The editor offers its checkmark").tap()++        let place = app.anyElement("work-detail-place")+        scrollUntilPresent(place, in: app, "The conversion survived Save")+        XCTAssertTrue(place.label.hasPrefix("Ada"), "was \(place.label)")+        XCTAssertFalse(+            app.anyElement("work-detail-character").exists,+            "The original was deleted with the conversion")+    }++    // MARK: - Req 3.2: the Places card reaches its own editor sheet++    /// `WorkCharacterPresentations` stacks a **second** `.sheet(item:)` for+    /// places on the same modifier chain the Characters card's editor sits on,+    /// bound to its own `expandedEditPlaceID`. Two sheets over one screen is+    /// exactly the arrangement where a card can end up handed the other card's+    /// id, so both ways into the place sheet are walked here.+    ///+    /// The stored place's line goes first, and what proves the sheet is the+    /// place one is the only words that differ between the two: a place's+    /// conversion offers to make it a character+    /// (`RecordKindPresentation.convertTitle`). Then "Add a place" mints a draft+    /// and opens the same sheet for naming, and the page's one Save writes it —+    /// nothing in the sheet is written before that.+    func testThePlacesCardOpensThePlaceEditorFromItsLineAndItsFooter() {+        openWorkDetail()+        openReview()+        tapReviewControl("character-review-keep-\(Row.kestrelHead)", "The place can be kept")+        closeReview()+        scrollUntilPresent(+            app.anyElement("work-detail-place"), in: app, "The kept place appears on the page")++        waitFor(app.buttons["work-detail-edit-button"], "The page offers its editor").tap()+        waitFor(app.anyElement("work-detail-title-field"), "The editor is open")++        openEditorLine(+            editLine(named: "Kestrel Head", kind: "place"), expecting: "character-editor", in: app,+            "The place's line opens its editor")+        let convert = app.buttons["work-detail-character-convert"]+        scrollUntilPresent(convert, in: app, "The place's editor offers the conversion")+        XCTAssertEqual(+            convert.label, "Make this a character",+            "The place's line opened the character card's sheet — was \(convert.label)")+        // Done rather than the conversion: this journey is about which sheet+        // opened, and the conversion has a journey of its own above.+        closeEditorSheet("character-editor", done: "character-editor-done", in: app)++        scrollUntilTappableAndTap(+            app.buttons["work-detail-add-place"], in: app,+            "The Places card offers its footer button")+        waitFor(+            app.anyElement("character-editor"), "The new place opens for naming", timeout: 20)+        let nameField = waitFor(+            app.textFields["work-detail-character-name-field"],+            "The editor offers the name field")+        nameField.tap()+        nameField.typeText("Bell Rock")+        closeEditorSheet("character-editor", done: "character-editor-done", in: app)++        scrollUntilPresent(+            editLine(named: "Bell Rock", kind: "place"), in: app,+            "The named draft is a line on the Places card")++        waitFor(app.buttons["work-detail-save-button"], "The editor offers its checkmark").tap()++        scrollUntilPresent(+            pill(named: "Bell Rock", kind: "place"), in: app, "The added place survived Save")+        scrollUntilPresent(+            pill(named: "Kestrel Head", kind: "place"), in: app,+            "…beside the place that was already there")+        XCTAssertFalse(+            app.anyElement("work-detail-character").exists,+            "Nothing the Places card did wrote a character")+    }++    // MARK: - Req 4.4: the entry a place's fact cites says so++    func testAKeptPlaceIsNamedOnTheEntryItCites() {+        openWorkDetail()+        openReview()+        tapReviewControl("character-review-keep-\(Row.kestrelHead)", "The place can be kept")+        closeReview()++        scrollUntilPresent(+            app.anyElement("work-detail-place"), in: app, "The kept place appears")++        // The two collections sit above Chapter Notes, so the chapter row is+        // 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()++        if !app.anyElement("entry-detail-citing-place").waitForExistence(timeout: 5) {+            scrollEntryDetail(in: app)+        }+        waitFor(+            app.anyElement("entry-detail-citing-place"),+            "The entry names the place citing it", timeout: 20)     }      // MARK: - Req 2.2: keeping, with a fact unticked and an alias struck@@ -192,12 +518,13 @@ final class CharacterExtractionUITests: XCTestCase {             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()+        tapReviewControl("character-review-keep-\(Row.ada)", "Ada can be kept")         waitUntilGone(-            app.anyElement("character-review-keep-ada"), "The decided row leaves the list",+            app.anyElement("character-review-keep-\(Row.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")+        XCTAssertTrue(+            reviewListShows("character-review-keep-\(Row.brede)"), "Brede is still undecided")          closeReview() @@ -238,8 +565,8 @@ final class CharacterExtractionUITests: XCTestCase {         openReview()         // Ada carries the alias; Brede's fact is the one grounded in the         // chapter note, so both are kept and each card is asked in turn.-        waitFor(app.anyElement("character-review-keep-ada"), "Ada can be kept").tap()-        waitFor(app.anyElement("character-review-keep-brede"), "Brede can be kept").tap()+        tapReviewControl("character-review-keep-\(Row.ada)", "Ada can be kept")+        tapReviewControl("character-review-keep-\(Row.brede)", "Brede can be kept")         closeReview()          waitFor(@@ -298,11 +625,12 @@ final class CharacterExtractionUITests: XCTestCase {         openWorkDetail()         openReview() -        waitFor(app.anyElement("character-review-skip-brede"), "Brede can be skipped").tap()+        tapReviewControl("character-review-skip-\(Row.brede)", "Brede can be skipped")         waitUntilGone(-            app.anyElement("character-review-skip-brede"), "The skipped row leaves the list",+            app.anyElement("character-review-skip-\(Row.brede)"), "The skipped row leaves the list",             timeout: 15)-        waitFor(app.anyElement("character-review-keep-ada"), "Ada is still undecided")+        XCTAssertTrue(+            reviewListShows("character-review-keep-\(Row.ada)"), "Ada is still undecided")          closeReview() @@ -329,7 +657,7 @@ final class CharacterExtractionUITests: XCTestCase {     func testAKeptCharacterIsNamedOnTheEntryItCites() {         openWorkDetail()         openReview()-        waitFor(app.anyElement("character-review-keep-brede"), "Brede can be kept").tap()+        tapReviewControl("character-review-keep-\(Row.brede)", "Brede can be kept")         closeReview()          waitFor(@@ -365,8 +693,8 @@ final class CharacterExtractionUITests: XCTestCase {     func testCombinePresentsItsDialogInPlaceAndQuoteTapsAreInert() {         openWorkDetail()         openReview()-        waitFor(app.anyElement("character-review-keep-ada"), "Ada can be kept").tap()-        waitFor(app.anyElement("character-review-keep-brede"), "Brede can be kept").tap()+        tapReviewControl("character-review-keep-\(Row.ada)", "Ada can be kept")+        tapReviewControl("character-review-keep-\(Row.brede)", "Brede can be kept")         closeReview()         waitFor(             app.anyElement("work-detail-character"), "The kept characters appear", timeout: 30)@@ -433,11 +761,18 @@ final class CharacterExtractionUITests: XCTestCase {         // 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()+        tapReviewControl("character-review-skip-\(Row.ada)", "Ada can be skipped")+        tapReviewControl("character-review-skip-\(Row.brede)", "Brede can be skipped")         closeReview() -        waitFor(app.buttons["work-detail-extract-characters"], "The manual trigger is offered").tap()+        let trigger = waitFor(+            app.buttons["work-detail-extract-characters"], "The manual trigger is offered")+        // `place-extraction` Req 2.1: one pass, both kinds, and the label says+        // so — a trigger reading "Look for characters" that returns places is+        // the same lie the indicator's noun was.+        XCTAssertEqual(+            trigger.label, "Look for characters and places", "was \(trigger.label)")+        trigger.tap()         // Req 1.11: a manual pass gets past the suppression the skips wrote, so         // it ends with proposals rather than with nothing.         waitFor(
Asterism/AsterismUITests/WideLayoutUITests.swift Modified +61 / -0
diff --git a/Asterism/AsterismUITests/WideLayoutUITests.swift b/Asterism/AsterismUITests/WideLayoutUITests.swiftindex ec655fb..6efbdd7 100644--- a/Asterism/AsterismUITests/WideLayoutUITests.swift+++ b/Asterism/AsterismUITests/WideLayoutUITests.swift@@ -300,6 +300,67 @@ final class WideLayoutUITests: XCTestCase {         waitFor(app.collectionViews["recent-list"], "The list is still beside it")     } +    // MARK: - `place-extraction` Reqs 2.2, 4.1 — the kind control and Places++    /// The review list's kind control and the work page's Places section, at+    /// regular width: the control is reachable in the sheet the wide layout+    /// presents, and the section lands inside the detail column rather than+    /// across the pane (Req 1.7's shape).+    ///+    /// **At the default text size**, not the largest. At `accessibility5` this+    /// device opens *collapsed* — which is what+    /// `WideLayoutAccessibilityUITests.testAccessibilityXXXLOpensCollapsedWithTheWiderListColumn`+    /// pins — so there is no sidebar row and no tab bar to select Works from,+    /// and the walk below cannot start. The accessibility-size half of these+    /// two surfaces is `AccessibilityJourneyUITests`', on the phone.+    ///+    /// Stub-driven like every extraction journey: `ASTERISM_UI_TEST_EXTRACTION`+    /// substitutes the scripted client, so nothing here waits on a model.+    func testThePlacesSectionAndReviewKindControlInTheDetailColumn() {+        app.launchEnvironment["ASTERISM_UI_TEST_EXTRACTION"] = "canned"+        launch("seeded-characters", orientation: .landscapeLeft)+        waitForLibrary()++        selectTab(.works, in: app)+        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"), "The work fills the detail column")++        waitFor(+            app.anyElement("work-detail-character-proposals"),+            "The sweep's proposals raise the indicator", timeout: 60)+        scrollUntilTappableAndTap(+            app.buttons["work-detail-review-characters-button"], in: app,+            "The indicator offers the review list")+        waitFor(app.anyElement("character-review-banner"), "The review list opens", timeout: 20)++        // Req 2.2: both segments are reachable in the sheet the wide layout+        // presents, at the size where a row of them stops fitting.+        for identifier in [+            "character-review-kind-place:kestrel head-character",+            "character-review-kind-place:kestrel head-place",+        ] {+            scrollUntilPresent(+                app.buttons[identifier], in: app, "The review list offers \(identifier)")+        }++        scrollUntilTappableAndTap(+            app.anyElement("character-review-keep-place:kestrel head"), in: app,+            "The place can be kept")+        waitFor(app.buttons["character-review-done"], "The review list closes", timeout: 20).tap()++        // Req 4.1: the Places section is in the detail column, not laid across+        // the whole pane — the shape a push has here (Req 1.7).+        let detailColumn = waitFor(+            app.anyElement("wide-detail-column"), "The detail column is laid out")+        let placePill = app.anyElement("work-detail-place")+        scrollUntilPresent(placePill, in: app, "The Places section draws in the wide layout")+        assertInsideColumn(placePill, column: detailColumn, what: "The kept place's pill")+        waitFor(app.collectionViews["works-list"], "…with the works list still beside it")+    }+     // MARK: - Q57 — a chapter replaces its work in the detail column      /// The user's interim ruling of 2026-09-02: selecting a chapter inside a
CHANGELOG.md Modified +113 / -0
diff --git a/CHANGELOG.md b/CHANGELOG.mdindex cd9e864..f278537 100644--- a/CHANGELOG.md+++ b/CHANGELOG.md@@ -125,6 +125,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Added +- **Places in the review sheet, the editor, the work page and entry+  detail (T-2276, phase 5).** The review sheet is titled "Suggested+  characters and places": every row carries its kind in the section+  caption, a candidate row wears a Character/Place switch above Keep and+  Skip that re-previews the match under the chosen kind and carries the+  reader's ticks by fact (Q78, Q79), a name the model returned under+  both kinds says so in one row, and a same-name record of the other+  kind is hinted rather than marked. Keep stays enabled on a reclassified+  candidate the model reported with no facts (Q80). The work page gains+  a Places section with the same card as characters, facts, alias chips,+  torn marker and citations included, an Add place action in edit mode,+  and a third structural action in the editor that converts the record+  to the other kind as a delete-and-recreate staged on Save and taken+  back before it (Decision 1, Q73). Entry detail lists the places citing+  it beside the characters. A torn place set renders its variants in+  the resolution sheet and Recent names the kind. Five review journeys,+  the accessibility journey at the largest size and a wide-layout case+  exercise the surfaces against a seeded fixture that now grounds a+  place fact in a chapter note. - **Creator UI suites, the seeded-creators fixture and the V12   documents (T-2316, phase 8).** A `seeded-creators` launch scenario   seeds four works, three creators, the seeded and reader roles, a@@ -497,6 +516,100 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).  ### Changed +- **Archive generation 12/13 (T-2276, phase 4).** The backup document+  is `BackupV12`, format 12 over schema 13, and an 11/12 file is refused+  by name (Q51). The payload gains `places` and `placeSuppressions`,+  each record carrying a non-optional work id, and the import runs one+  generic body per record kind through the row protocol: an archived+  place naming a work this library does not hold is kept as an orphan+  and exported as-is, and on the update path an archived work id is+  adopted only when it resolves or the local row is itself an orphan+  (Q76). A test now ties the archive's schema version to the live+  schema, so a future bump cannot forget the fourth thing. The golden+  export is re-recorded at 12/13 with two places and two place+  suppressions, one of them the orphan shape.+- **The extraction bridge and coordinator decide by kind (T-2276,+  pipeline tasks 18 and 19).** The decision request carries the+  displayed kind, the kinds the model returned the name under, and the+  displayed target, so a reclassified single-kind skip suppresses the+  displayed kind only and a union row skip suppresses both. The+  coordinator keeps the extraction context per work, refreshed by+  reconcile and cleared on a memory warning, and its discard, retarget+  and new reclassify are keyed by kind plus name key; the cross-kind+  discard sweeps the same name under the other kind only when the commit+  suppressed it there. The canned UI-test result gains a place and a+  name returned under both kinds, grounded in the seeded fixture's+  notes. Until the review sheet lands, a place row still draws as an+  unlabelled character row.+- **The repository runs over both record kinds (T-2276, phase 3).** The+  duplicate scan, reconciler, resolution, workload, redirect, work+  deletion, merge and group projection gain place arms beside the+  character ones: same-UUID place groups converge in place, collapses+  re-point citations for both kinds inside one throwing scope before the+  save, torn place sets route to the reader, and an orphan place with a+  dangling work id is tolerated on every path and never swept. The+  candidate read carries both kinds per examined work, the decision+  commit switches on kind into one generic body writing to the kind's own+  suppression table, and a skip suppresses the other kind only for a row+  the model returned under both. `commitRecordEdits` replaces the+  character editor commit with a convert operation that deletes and+  recreates the record under the other kind in the same save, minting a+  fresh id, carrying the retained name key and suppressing the source's+  facts under the old kind (Q73). Work detail and entry detail read+  places through one fetch over the group's works, and the M4 suite gains+  a `place-ranking-200x50` arm under the shared ranking budget and+  ceiling; the reconcile fixture is unchanged (Q74). Two names from the+  design's rename table stayed put, with reasons (Q71, Q72).+- **The extraction request returns places beside characters (T-2276,+  pipeline tasks 16 and 17).** `ExtractionResult` gains `places` in the+  shape the prototype settled, with the prototype's final instructions+  text byte for byte (Q62). Grounding runs one rule set over both+  arrays with a per-kind candidate cap and names the kind on every+  drop line, the reader content still readable in `Development` only.+  The assembler carries a `RecordKind` per candidate: a name returned+  under both kinds folds into one character row remembering both kinds+  (Q12, Q20, Q70), and the existing rule that an unmatched copy with+  nothing left to decide is not shown stays as it was (Q68). The ledger+  is keyed by `ProposalKey`, kind plus name key, and gains `reclassify`;+  a merged row keeps the reader's kind and the older target (Q69). The+  extraction context holds per-kind dictionaries. The coordinator's+  discard and retarget still build a character key until the bridge+  and coordinator tasks land, so on this state a place row cannot yet+  be discarded from the review list.+- **Schema V13 with `Place` and `PlaceSuppression` (T-2276, phase 2).**+  The live schema is V13: two new tables on the V6-onward shape, every+  column defaulted or optional, no relationship, `workID` a UUID column,+  both conforming to `RecordRow` and `SuppressionRow`. V12 is the one+  frozen snapshot and V11 is retired with its recorded-store fixture and+  the marker-twelve suite, in the one freeze change the migration note+  requires (Q65); the plan is `[V12, V13]` with one lightweight stage.+  The readiness marker moves to `"13"` with `"12"` still openable by+  the app and refused by the extension, and no new bootstrap case is+  added because the lagging state already carries the generation (Q64).+  The archive stays at 11/12 until the archive phase, with `Place`+  borrowing the character record as an interim archive type (Q66). The+  M5 support seeds places and place suppressions, the marker literals in+  the classifier, coverage and lifecycle suites move one generation on,+  and the graph baseline is hand-edited to format 10 with the two new+  empty tables.+- **The character store is generic over a record row (T-2276, phase 1).**+  Ahead of places, the store code that handled characters became generic+  over a `RecordRow` protocol, with a `SuppressionRow` beside it and a+  `RecordKind` naming the kind. `CharacterRecord` and+  `CharacterSuppression` conform, each owning its own predicates; groups,+  duplicate sets, citation repointing, ranking and the work-page+  presentation are now `RecordGroup<Row>`, `RecordDuplicateSet`,+  `CitationRepointing`, `RecordRanking` and `WorkRecordPresentation`+  carrying a `kind`, with `CharacterGroup` kept as a typealias so call+  sites read unchanged. `facts` moved off the model onto the protocol as+  one decode for every record table (Q63). JSON coding keys, raw enum+  values, the archive record types, `MarkdownExport` and the share sheet+  row are untouched, so blobs and archives decode as before. Files+  followed their types: `RecordFacts`, `RecordGroups`, `RecordRanking`,+  `WorkRecordPresentation`, `LibraryRepository+RecordExtraction` and+  `+RecordEditing`. Two new suites cover the protocol on characters and+  the generic group; the existing character suites are the regression+  net for the renames. - **The work edit screen reorganised around captioned cards, compact   lines and editor sheets (T-2316, Decision 7).** After the first   creator build was tried on a phone, the bare "Add a creator" and
CLAUDE.md Modified +22 / -14
diff --git a/CLAUDE.md b/CLAUDE.mdindex 21910e5..1e8223c 100644--- a/CLAUDE.md+++ b/CLAUDE.md@@ -64,11 +64,11 @@ 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 **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-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`, which since `place-extraction` is the **combined** result and carries the places beside the characters — 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), preceded by `build-mac`: a macOS compile failure fails it (Req 9.1). The Mac build is never installed or launched. `SKIP_MAC=1` drops that dependency loudly and owes a clean `make build-mac` before the push. - `make test` / `make test-ui` — full suites (simulator, iPhone); they skip the iPad-only suites by name - `make test-ui-ipad` — the wide-layout and wide-layout-accessibility suites on `IPAD_SIMULATOR` (simulator, safe)-- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **nine** since `work-creators` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`), and **ten on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12**, the one on a path the reader waits on; V12's three new tables are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s) and so did V12 (0.0308 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0109–0.0112 s over 500 links: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0626–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent tenth is that spec's `creator-converge-noop`, in budget at 0.0094–0.0100 s against 10 ms across four samples and never more than 6% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests` and `work-creators` adds `M4CreatorScalePerformanceTests`, so the suite count is 7 and the test count 40, measured as one run at 1,142 s. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/work-creators/verification-run.md` for the current numbers, `specs/series-and-related-works/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band.+- `make test-performance-m4` — M4 Core budgets, host only, no device, safe to run. **~21 minutes** (1,093 s of test time measured 2026-08-28, 1,120 s over 28 tests on 2026-08-30 after `character-ranking` added its own, and **1,120.6 s over 32 tests on 2026-09-05** after T-1910 added four Work-bearing preview arms — all quiet-host runs, all `EXIT=0` with the same eight known issues, so the 21 minutes still holds and the four added arms cost no measurable wall time. T-2093 took the settling pass from ~7.3 s to ~1.7 s per sample; three contended runs on 2026-09-05 measured 1,136 s, 1,237 s and 1,085 s over the same 32 tests, all `EXIT=0` with **seven** known issues. A *loaded* run of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. Add a ~190 s release build to any of them): most of it is the worst-case single-hostname consolidation in `M4ScalePerformanceTests` (5 samples, each paying its own ~40 s divert before a ~40 s measurement) and the Req 10.1 settling pass (10 samples, each re-seeding 1,350 duplicate rows plus an untimed observation pass). The V4→V5 migration measurement is **gone** — `retire-migration-chain` deleted the pass it timed along with the suite. **The target exits 0 on a quiet host**, with the accepted breaches reported as `withKnownIssue` known issues rather than failures — **nine** since `work-creators` (four before `multi-site-works`, nine after it, eight after `drop-superseded-columns`, seven after T-2093, eight after `series-and-related-works`), and **ten on a loaded host**, because `creator-converge-noop` is wrapped `isIntermittent` and only records when the host is busy. Three are long-standing: Req 5.5's three diagnosis re-derivations. Three are Req 5.4's capture-projection arms (0.093–0.102 s pre-V8 → 0.160–0.170 s at V9 → 0.169–0.176 s at V10 → **0.179–0.205 s at V12** → 0.158–0.164 s at V13 on a quieter host, the one on a path the reader waits on; V12's three new tables and V13's two are empty on that path and cost it nothing the run's own host variance does not explain, still well inside a 250 ms ceiling). The seventh is the **full**-tier no-op reconcile, and V9 recovered most of it: 1.07 s → **0.0296–0.0302 s** once `V8PopulationPass` was deleted with the columns and `MembershipReconciler.heal` was gated on the diagnosis, which is still 3.0× a 10 ms ceiling drawn before the library had a membership table; V10 left it there (0.0301 s), V12 did too (0.0308 s) and so did V13 (0.0302 s). Req 10.1's *observation* pass **retired** with that fall (2.69 s → 1.01 s, back inside its 2 s budget, and 1.02 s at V10), and its **settling** pass retired at T-2093: 88% of that pass was SwiftData maintaining `Site.entries` inside the deletion phase's one `save` (6.42 s before the fix, 0.41 s after; the measured 8.0 ms per deleted row over a 5,000-row Site is supporting evidence, not the arithmetic — only the 250 Entry losers of the 300 deleted rows sit in `Site.entries`), and detaching a chunk's doomed rows from their Site in one rewrite of that array took it from 7.3 s to ~1.6 s, inside its 2 s budget, with the 11 s floor under the known issue gone too (`specs/bugfixes/settling-pass-budget/`, Decision 32). The eighth is `series-and-related-works` Req 14.6's link dedupe, a 10 ms budget measured at 0.0100–0.0112 s over 500 links — the low end is `place-extraction`'s run at 0.010032 s, 0.3% over the budget and the closest it has come to fitting, which would turn a quiet host into a *second* way to be red: the whole-table fetch the phase opens with is 79–83% of that, so the budget sits under what SwiftData charges to materialise the rows (Q59 of that spec). The ninth is `work-creators` Req 11.6's credit dedupe, a 50 ms budget measured at 0.0591–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (Q73 of that spec, 130 ms ceiling). The intermittent tenth is that spec's `creator-converge-noop`, in budget at 0.0081–0.0100 s against 10 ms across five samples and never more than 19% clear of it, so it is asserted `isIntermittent` with a 20 ms ceiling (Q74). Its other creator arms are `credits-resolve-and-filter` at 0.0144–0.0154 s under a 20 ms budget, `dedupe-credits-fetch` at 0.0477–0.0496 s reported only, `creator-detail` at 0.0363–0.0376 s under 50 ms, and the two read arms `works-snapshot-creators` (1.75–2.11 s) and `creators-list` (0.274–0.284 s) under the 3 s class ceiling. `series-and-related-works` adds `M4SeriesScalePerformanceTests` and `work-creators` adds `M4CreatorScalePerformanceTests`, so the suite count is 7 and the test count **41** since `place-extraction` added one arm and no suite, measured as one run at **1,070 s**. That arm is `place-ranking-200x50` at **0.0035 s** against a 10 ms budget and a 50 ms ceiling, recorded beside its sibling `character-ranking-200x50` at **0.0037 s** — the ranker is one generic implementation over `RecordRow` and the point of the arm is that the second conformance costs what the first does. The character arm's own number moved up from 0.0023–0.0025 s in the same change, which is the cost of `CharacterRanking` becoming `RecordRanking`; read 0.0037 s as its new resting place, not as a regression. Every one has a regression ceiling asserted *outside* its known-issue block, so a run that drifts further still fails; `RUNS=3` completes all three runs. See `specs/place-extraction/verification-run.md` for the current numbers, `specs/work-creators/verification-run.md` and `specs/bugfixes/settling-pass-budget/` for the previous ones, `specs/series-and-related-works/verification-run.md` for the ones before those, `specs/work-and-reading-status/verification-run.md` §4 for the previous ones, `specs/drop-superseded-columns/verification-run.md` and `specs/multi-site-works/verification-run.md` §4 and §7 for the previous ones, and `docs/agent-notes/testing.md` for recording a band. - `make test-performance-chunks` — host-only calibration sweep of the shared bulk chunk constant (import commits and the reconciler re-pin). No device, safe to run, but gated on `ASTERISM_RUN_CHUNK_SWEEP=1` and **~20 minutes per run**, so it is deliberately *not* part of `make test-performance-m4`. It asserts nothing — a calibration is reported, not budgeted. Re-run it when the bulk write paths change (Q53 and the task 25 section of `specs/cloudkit-mirroring/implementation.md`). - `make test-performance-m4-recent` — **physical device, see above** @@ -83,9 +83,9 @@ bar. `make verify-identity` (a `test-core` prerequisite) is an identity lint, not a style one: it checks the App Group / CloudKit identifier declarations without building anything — see `specs/configuration-identity/`. -**The live store schema is V12** (`specs/work-creators/`), the readiness marker-generation is `"12"`, `AsterismSchemaV11` is the one frozen snapshot left, and-the archive generation is 11/12. `docs/agent-notes/schema-migration.md` is the+**The live store schema is V13** (`specs/place-extraction/`), the readiness+marker generation is `"13"`, `AsterismSchemaV12` is the one frozen snapshot left,+and the archive generation is 12/13. `docs/agent-notes/schema-migration.md` is the procedure for moving all four.  `Development` builds carry a **Run background export** button in Settings,@@ -124,7 +124,8 @@ for that reason. 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`. Character extraction logs the same shape under-`category:CharacterExtraction` — both go through one body (`PipelineLog`), so+`category:CharacterExtraction` — places included since `place-extraction`, which+moved no category name. 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@@ -154,7 +155,7 @@ before touching `Models.swift`.  **Every model lives in `AsterismCore`, nested inside the live schema enum.** There are zero top-level `@Model` types; `Entry`, `Work`, `Site` and the rest-are typealiases onto `AsterismSchemaV12`. One frozen snapshot is kept beside+are typealiases onto `AsterismSchemaV13`. One frozen snapshot is kept beside it, and the migration plan is always `[V(n-1), V(n)]` with one lightweight stage. A custom migration stage is forbidden: it never fires between structurally identical models and it would run inside the share extension,@@ -167,11 +168,17 @@ defaulted and non-optional or optional. No uniqueness constraint exists on any entity, and `ModelContractTests` fails the build if one appears. Every relationship that exists is optional with an explicit inverse (`immutable-capture-safety-net` Decision 22), and every table added since V6-declares no relationship at all.+declares no relationship at all **except `Character` and `CharacterSuppression`**,+V7's pair, which predate the standing rule and still carry an inverse to `Work`.+Nothing has copied them since: `Place` and `PlaceSuppression` address their work+by UUID column (`place-extraction`), which is why the store's generic record code+lets each conformance own its own fetch.  **A cross-entity reference is a UUID column, not a relationship.** This is the-standing rule, made three times (`configurable-work-types` Decision 8,-`series-and-related-works` Decision 6, `work-creators` Decision 6). An inverse+standing rule, made four times (`configurable-work-types` Decision 8,+`series-and-related-works` Decision 6, `work-creators` Decision 6, and+`place-extraction`, where `Place` takes the `WorkCredit` shape rather than+copying `Character`'s inverse). An inverse faults every row on the other side, a `.nullify` on an absent target erases a value that must survive as unresolved while the target is still in transit, and a dangling reference has to be a tolerated state, not damage. Referential@@ -402,10 +409,11 @@ a `modelContext` to a SwiftUI view is the wrong direction whatever the reason.  **Nothing runs before Save.** A projection (reparse, merge, teaching, capture) is read-only and the commit re-derives what it needs under the lock. A rule-suggestion or an extracted character is prefilled into the draft with a-marker and accepted by the ordinary Save; nothing is written without-acceptance (`rule-suggestion` Q2, Q9). Character combine and delete are-staged in the edit session and applied in one commit; the review sheet is+suggestion or an extracted character or place is prefilled into the draft with+a marker and accepted by the ordinary Save; nothing is written without+acceptance (`rule-suggestion` Q2, Q9). Combine, delete and — since+`place-extraction` — **converting a record between kinds** are staged in the+edit session and applied in one commit; the review sheet is unreachable while the page is in edit mode so its reload cannot rebuild the drafts under the reader (`character-extraction` Q97, Q109). A conflict on Save must not call `load()`, because that reassigns every draft
Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift Modified +65 / -33
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swiftindex 6d7da99..12331ab 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ArchiveRecordBuilders.swift@@ -25,7 +25,7 @@ import SwiftData /// archive's own. internal enum ArchiveRecordBuilders { -    static func makeSite(_ record: BackupV11Site) -> Site {+    static func makeSite(_ record: BackupV12Site) -> Site {         let site = Site(hostname: record.hostname, displayName: record.displayName)         site.modeRaw = record.mode.rawValue         site.junkSuffixRule = record.junkSuffixRule@@ -33,7 +33,7 @@ internal enum ArchiveRecordBuilders {     }      static func makeTitlePattern(-        _ record: BackupV11TitlePattern, site: Site?+        _ record: BackupV12TitlePattern, site: Site?     ) throws -> TitlePattern {         return try TitlePattern(             id: record.id,@@ -48,7 +48,7 @@ internal enum ArchiveRecordBuilders {     }      static func makeURLRule(-        _ record: BackupV11URLRule, site: Site?+        _ record: BackupV12URLRule, site: Site?     ) throws -> URLRulePattern {         try URLRulePattern(             id: record.id,@@ -64,7 +64,7 @@ internal enum ArchiveRecordBuilders {     /// The wire timestamps are what an import-created row carries on both fields     /// (Q33), and an unrecognised state coerces to `.active` rather than     /// refusing — a type row from a later build's wider set is legal data.-    static func makeWorkType(_ record: BackupV11WorkType) -> WorkTypeEntity {+    static func makeWorkType(_ record: BackupV12WorkType) -> WorkTypeEntity {         makeWorkType(             id: record.id, name: record.name,             state: ToleratedEnum.read(record.stateRaw, default: .active),@@ -91,7 +91,7 @@ internal enum ArchiveRecordBuilders {     /// Work has no site column to name: its site presence is its     /// `WorkSiteMembership`, which `makeMembership` below builds from the     /// archive's own records.-    static func makeWork(_ record: BackupV11Work) -> Work {+    static func makeWork(_ record: BackupV12Work) -> Work {         let work = Work(             id: record.id,             displayTitle: record.displayTitle,@@ -105,7 +105,7 @@ internal enum ArchiveRecordBuilders {     /// carries it. `workID` travels whether or not the Work is there (Q37), so an     /// orphan re-attaches when its Work arrives (Req 8.3, 9.5).     static func makeMembership(-        _ record: BackupV11Membership, work: Work?, site: Site?+        _ record: BackupV12Membership, work: Work?, site: Site?     ) -> WorkSiteMembership {         WorkSiteMembership(             id: record.id,@@ -123,7 +123,7 @@ internal enum ArchiveRecordBuilders {     /// One dismissed pair. The record's ids are already in the canonical sorted     /// order — `BackupImportPayload` normalises them at the door — so nothing     /// here re-sorts and then disagrees about which end is which.-    static func makeDistinctPair(_ record: BackupV11DistinctPair) -> WorkDistinctPair {+    static func makeDistinctPair(_ record: BackupV12DistinctPair) -> WorkDistinctPair {         WorkDistinctPair(             id: record.id, lowerWorkID: record.lowerWorkID,             higherWorkID: record.higherWorkID, recordedAt: record.recordedAt)@@ -133,7 +133,7 @@ internal enum ArchiveRecordBuilders {     /// nothing derived: the name and notes are stored trimmed by every writer     /// and the reference checks refuse an empty name, so the record's values go     /// in as they arrived.-    static func makeSeries(_ record: BackupV11Series) -> Series {+    static func makeSeries(_ record: BackupV12Series) -> Series {         Series(             id: record.id, name: record.name, notes: record.notes,             createdAt: record.createdAt, modifiedAt: record.modifiedAt)@@ -142,7 +142,7 @@ internal enum ArchiveRecordBuilders {     /// One link row. The record's ids are already in the canonical sorted order     /// — `BackupImportPayload` normalises them at the door — so nothing here     /// re-sorts and then disagrees about which end is which.-    static func makeLink(_ record: BackupV11Link) -> WorkLink {+    static func makeLink(_ record: BackupV12Link) -> WorkLink {         WorkLink(             id: record.id, lowerWorkID: record.lowerWorkID,             higherWorkID: record.higherWorkID, linkType: record.linkType,@@ -156,7 +156,7 @@ internal enum ArchiveRecordBuilders {     /// import once and stay put: the next sync fold and a repeated import see     /// exactly what the exporting device saw and write nothing. An unrecognised     /// state coerces to `.active` at every read site, so the raw column travels.-    static func makeCreator(_ record: BackupV11Creator) -> Creator {+    static func makeCreator(_ record: BackupV12Creator) -> Creator {         let row = Creator(             id: record.id, name: record.name, notes: record.notes,             stateRaw: record.stateRaw, canonicalID: record.canonicalID)@@ -169,7 +169,7 @@ internal enum ArchiveRecordBuilders {     }      /// One role row, the same way, with the archive's list position.-    static func makeCreatorRole(_ record: BackupV11CreatorRole) -> CreatorRole {+    static func makeCreatorRole(_ record: BackupV12CreatorRole) -> CreatorRole {         makeCreatorRole(record, position: record.position, at: record.positionModifiedAt)     } @@ -178,7 +178,7 @@ internal enum ArchiveRecordBuilders {     /// stamped at import time so a later-arriving sync row cannot undo the     /// placement (Req 9.4, Q38).     static func makeCreatorRole(-        _ record: BackupV11CreatorRole, position: Int, at positionModifiedAt: Date+        _ record: BackupV12CreatorRole, position: Int, at positionModifiedAt: Date     ) -> CreatorRole {         let row = CreatorRole(             id: record.id, name: record.name, position: position,@@ -198,14 +198,14 @@ internal enum ArchiveRecordBuilders {     /// render, never a row to drop. `roleIDs` is normalised on the way in for     /// the reason every writer normalises it: equal sets have to be equal     /// arrays.-    static func makeCredit(_ record: BackupV11Credit) -> WorkCredit {+    static func makeCredit(_ record: BackupV12Credit) -> WorkCredit {         WorkCredit(             id: record.id, workID: record.workID, creatorID: record.creatorID,             roleIDs: WorkCreditSupport.roleIDs(record.roleIDs),             createdAt: record.createdAt, modifiedAt: record.modifiedAt)     } -    static func makeEntry(_ record: BackupV11Entry) -> Entry {+    static func makeEntry(_ record: BackupV12Entry) -> Entry {         let entry = Entry(             id: record.id,             captureTitle: record.captureTitle,@@ -220,25 +220,57 @@ internal enum ArchiveRecordBuilders {         return entry     } -    static func makeCharacter(_ record: BackupV11Character) -> CharacterRecord {-        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-        return character-    }--    /// The raw columns travel verbatim, so a value written by a later build's-    /// wider set survives the round trip rather than being coerced to this-    /// build's default.-    static func makeSuppression(_ record: BackupV11Suppression) -> CharacterSuppression {-        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+    /// A record row of any kind, on `makeEntry`'s shape: identity from the+    /// conformance's constructor, content from the one mutable half.+    ///+    /// `apply` writes every authored column and both timestamps, so the+    /// constructor is handed identity and the creation stamp and nothing else.+    /// The **raw** enum columns travel verbatim through it, so a value written+    /// by a later build's wider set survives the round trip rather than being+    /// coerced to this build's default.+    ///+    /// Ownership is **not** applied here — the caller's+    /// `attach(to:archivedWorkID:)` writes it, so an owner the archive names but+    /// the library cannot resolve still round-trips (`place-extraction` Q60).+    /// A row built with no work therefore starts orphaned, which is what+    /// `RecordRow.make(… work: nil)` means for both tables.+    static func makeRecord<Row: RecordRow>(+        _ type: Row.Type, _ record: some ArchivedRecord+    ) -> Row {+        let row = Row.make(+            id: record.recordID, name: "", nameKey: "", aliases: [], note: "",+            facts: [], timestamp: record.createdAt, work: nil)+        LibraryRepository.apply(record, to: row)         return row     }++    /// `makeRecord`'s suppression half, on the same terms.+    static func makeSuppressionRow<Row: SuppressionRow>(+        _ type: Row.Type, _ record: some ArchivedSuppression+    ) -> Row {+        let row = Row.make(+            id: record.recordID, work: nil, kind: .candidate, nameKey: "",+            source: nil, evidence: nil, status: .active, actionAt: record.actionAt)+        LibraryRepository.apply(record, to: row)+        return row+    }++    // The four named entry points stay: each table's `make(imported:)` names its+    // own archive record, and the generic pair above is the body they share.++    static func makeCharacter(_ record: BackupV12Character) -> CharacterRecord {+        makeRecord(CharacterRecord.self, record)+    }++    static func makeSuppression(_ record: BackupV12Suppression) -> CharacterSuppression {+        makeSuppressionRow(CharacterSuppression.self, record)+    }++    static func makePlace(_ record: BackupV12Place) -> Place {+        makeRecord(Place.self, record)+    }++    static func makePlaceSuppression(_ record: BackupV12PlaceSuppression) -> PlaceSuppression {+        makeSuppressionRow(PlaceSuppression.self, record)+    } }
Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift Modified +3 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swiftindex a93aa5f..2a66ae9 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismCapabilities.swift@@ -15,8 +15,8 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable {         /// and the archive it writes is format 11 over schema 12. Nothing about a         /// *rule form* changes with it — every `supports…` answer below is m4's         /// — so the case exists to name the store shape and the rule-form set,-        /// which is what `BackupV11Codec` stamps. The literal has not moved with-        /// any archive generation since: none of 8/9 through 11/12 changes those+        /// which is what `BackupV12Codec` stamps. The literal has not moved with+        /// any archive generation since: none of 8/9 through 12/13 changes those         /// two things (`rule-citation-by-uuid` Q19), and the generation is named         /// by its format and schema numbers, which are what the importer gates         /// on.@@ -32,7 +32,7 @@ public struct AsterismCapabilities: Codable, Equatable, Sendable {     public static let multiSite = AsterismCapabilities(gate: .multiSite)      /// The current runtime gate is `.multiSite` (`multi-site-works` Q29).-    /// `BackupV11Codec` stamps the literal `"multi-site"` rather than reading+    /// `BackupV12Codec` stamps the literal `"multi-site"` rather than reading     /// this value, so the archive's gate is independent of the runtime's.     /// Earlier gates stay available because the schema and teaching suites still     /// exercise them — `CapabilityGatingTests`, `PhraseParsingTests`,
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swift Deleted +0 / -272
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swiftdeleted file mode 100644index f46a685..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swift+++ /dev/null@@ -1,272 +0,0 @@-import Foundation-import SwiftData--/// The frozen `series-and-related-works` schema — the shape every installed-/// library was written by before `work-creators`, and the `from` version of the-/// V11 → V12 lightweight stage.-///-/// V11 was V10 **plus** two optional `Work` columns — `seriesID` and-/// `seriesPosition` — and two new tables, `Series` and `WorkLink`. Nothing else-/// moved. V12 adds to it in turn: three new tables — `Creator`, `CreatorRole`-/// and `WorkCredit` — and no `Work` column at all.-///-/// V11 is frozen for the same reason V5, V6, V7, V8, V9 and V10 were: *any* edit-/// to its body makes a V11-recorded store refuse to open with-/// `NSCocoaErrorDomain` 134504, "Cannot use staged migration with an unknown-/// model version". The live classes therefore moved to `AsterismSchemaV12`, and-/// this declaration exists only to give `AsterismV12MigrationPlan` the `from`-/// version of its **only** stage — and to let `V11RecordedStoreFixture` seed a-/// genuinely 11.0.0-recorded store in-process. It is the last snapshot the-/// package declares: the V10 → V11 stage and `AsterismSchemaV10` retired with-/// this freeze, on `retire-migration-chain` Decision 6's population-/// precondition, confirmed by the owner on 2026-09-07 (`work-creators` Q15).-///-/// The classes are nested so they can carry the same SwiftData entity names-/// ("Entry", "Site", …) as the live V12 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 V11-shaped object-/// at runtime, so these carry stored columns only — no accessors, no business-/// logic.-///-/// # These snapshots are frozen *by reference*, not only by file-///-/// The nesting freezes the class bodies; it does **not** freeze anything a body-/// *names*. Editing one of those changes the stored shape of this frozen schema-/// silently — and that is exactly what makes a recorded store refuse to open-/// (134504). Two families of referent, both live and both shared with V12:-///-/// * **The stored value types.** `JunkSuffixRule` is a top-level type in-///   `ValueObjects.swift`; its stored properties are this schema's stored-///   properties.-/// * **Every enum whose raw value is baked into a default.** A default is part-///   of the shape, so `CaptureTitleSource.manual.rawValue`,-///   `EntryIdentityBasis.conservative.rawValue`, `TitleProvenance.manual`,-///   `SiteMode.untaught.rawValue`, `URLRuleOrigin.readerTaught`,-///   `WorkTypeState.active`, `CharacterSuppressionKind.candidate`,-///   `CharacterSuppressionStatus.active`, `WorkURLIdentityState.none`,-///   `WorkStatus.ongoing` and `ReadingStatus.reading` are all frozen *spellings*-///   here, not merely frozen references. Renaming a case, or reordering one-///   whose raw value is derived rather than written out, edits this file without-///   touching it.-///-///   **V12 adds no new baked-in raw value to this file.** Its three new tables-///   default `stateRaw` to the *literal* `"active"` rather than to a case's-///   `rawValue`, precisely so the next freeze inherits one fewer frozen-///   spelling; `Series` and `WorkLink` already default to empty strings, epoch-///   dates and fresh UUIDs.-public enum AsterismSchemaV11: VersionedSchema {-    public static let versionIdentifier = Schema.Version(11, 0, 0)--    public static var models: [any PersistentModel.Type] {-        [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,-         WorkTypeEntity.self, Character.self, CharacterSuppression.self,-         WorkSiteMembership.self, WorkDistinctPair.self,-         Series.self, WorkLink.self]-    }-}--extension AsterismSchemaV11 {-    @Model-    public final class Entry {-        public var id: UUID = UUID()-        public var captureTitle: String = ""-        public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue-        public var rawURLString: String = ""-        public var canonicalURLString: String?-        public var hostname: String = ""-        public var site: Site?-        public var entryIdentityKey: String = ""-        public var conservativeIdentityKey: String = ""-        public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue-        public var urlWorkIdentity: String?-        public var chapterSequence: String?-        public var chapterTitle: String?-        public var note: String = ""-        public var ratingRaw: String?-        public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)-        public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-        public var work: Work?-        public var intentionallyUnattached: Bool = false-        public var characterExtractionFingerprint: String?-        public var citationsData: Data?--        public init() {}-    }--    @Model-    public final class Work {-        public var id: UUID = UUID()-        public var displayTitle: String = ""-        public var lastParsedTitle: String?-        public var genericNotes: String = ""-        public var workTypeID: UUID?-        public var genreTags: [String] = []-        public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue-        /// V10's three additions. Their property initialisers are the Core Data-        /// attribute defaults the V9 → V10 stage wrote into every existing row,-        /// which is why the two enum spellings are frozen here (see the header).-        public var workStatusRaw: String = WorkStatus.ongoing.rawValue-        public var readingStatusRaw: String = ReadingStatus.reading.rawValue-        public var verdict: String = ""-        /// V11's two additions, both **optional**: nil is "this work is in no-        /// series", so the V10 → V11 stage had no attribute default to write.-        public var seriesID: UUID?-        public var seriesPosition: Double?-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-        public var genericNotesExtractionFingerprint: String?-        @Relationship(deleteRule: .nullify, inverse: \Entry.work)-        public var entries: [Entry]?-        @Relationship(deleteRule: .nullify, inverse: \Character.work)-        public var characters: [Character]?-        @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)-        public var characterSuppressions: [CharacterSuppression]?-        @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.work)-        public var siteMemberships: [WorkSiteMembership]? = []--        public init() {}-    }--    @Model-    public final class Site {-        public var hostname: String = ""-        public var displayName: String = ""-        public var modeRaw: String = SiteMode.untaught.rawValue-        @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)-        public var patterns: [TitlePattern]?-        @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)-        public var urlRules: [URLRulePattern]?-        /// Inverse of `Entry.site`, present only because CloudKit requires every-        /// relationship to have one. Internal for the same reason the live class-        /// keeps it internal (Q17): traversing it faults every Entry for a-        /// hostname.-        @Relationship(deleteRule: .nullify, inverse: \Entry.site)-        var entries: [Entry]?-        /// Inverse of `WorkSiteMembership.site`. Same reasoning again.-        @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site)-        var workMemberships: [WorkSiteMembership]?-        public var junkSuffixRule: JunkSuffixRule?--        public init() {}-    }--    @Model-    public final class TitlePattern {-        public var id: UUID = UUID()-        public var version: Int = 1-        public var isActive: Bool = false-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var definitionData: Data?-        public var site: Site?--        public init() {}-    }--    @Model-    public final class URLRulePattern {-        public var id: UUID = UUID()-        public var version: Int = 1-        public var isCurrent: Bool = false-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var originRaw: String = URLRuleOrigin.readerTaught.rawValue-        public var definitionData: Data = Data()-        public var site: Site?--        public init() {}-    }--    @Model-    public final class WorkTypeEntity {-        public var id: UUID = UUID()-        public var name: String = ""-        public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)-        public var stateRaw: String = WorkTypeState.active.rawValue-        public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)-        public var canonicalID: UUID?-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)--        public init() {}-    }--    @Model-    public final class Character {-        public var id: UUID = UUID()-        public var name: String = ""-        public var nameKey: String = ""-        public var aliases: [String] = []-        public var note: String = ""-        public var factsData: Data?-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)-        public var work: Work?--        public init() {}-    }--    @Model-    public final class CharacterSuppression {-        public var id: UUID = UUID()-        public var work: Work?-        public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue-        public var nameKey: String = ""-        public var sourceKindRaw: String?-        public var sourceEntryID: UUID?-        public var evidence: String?-        public var statusRaw: String = CharacterSuppressionStatus.active.rawValue-        public var actionAt: Date = Date(timeIntervalSince1970: 0)--        public init() {}-    }--    @Model-    public final class WorkSiteMembership {-        public var id: UUID = UUID()-        public var hostname: String = ""-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var urlIdentity: String?-        public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue-        public var urlIdentityRuleID: UUID?-        public var workURLString: String?-        public var workID: UUID?-        public var work: Work?-        public var site: Site?--        public init() {}-    }--    @Model-    public final class WorkDistinctPair {-        public var id: UUID = UUID()-        public var lowerWorkID: UUID = UUID()-        public var higherWorkID: UUID = UUID()-        public var recordedAt: Date = Date(timeIntervalSince1970: 0)--        public init() {}-    }--    @Model-    public final class Series {-        public var id: UUID = UUID()-        public var name: String = ""-        public var notes: String = ""-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)--        public init() {}-    }--    @Model-    public final class WorkLink {-        public var id: UUID = UUID()-        public var lowerWorkID: UUID = UUID()-        public var higherWorkID: UUID = UUID()-        public var linkType: String = ""-        public var createdAt: Date = Date(timeIntervalSince1970: 0)-        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)--        public init() {}-    }-}
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift Modified +301 / -47
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swiftindex a997e13..0803f7c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift@@ -1,19 +1,61 @@ import Foundation import SwiftData -/// The runtime schema. Its body is `Models.swift`, which opens-/// `extension AsterismSchemaV12`.+/// The frozen `work-creators` schema — the shape every installed library was+/// written by before `place-extraction`, and the `from` version of the+/// V12 → V13 lightweight stage. ///-/// V12 is V11 **plus three new tables** — `Creator`, `CreatorRole` and-/// `WorkCredit` (`work-creators`). Nothing else moves: **no `Work` column is-/// added**, no existing column changes type, and no relationship changes shape.-/// It is the first stage in the project's history that adds *only* tables.+/// V12 was V11 **plus** three new tables — `Creator`, `CreatorRole` and+/// `WorkCredit` — and no `Work` column at all. V13 adds to it in turn: two new+/// tables, `Place` and `PlaceSuppression`, and again no column on any existing+/// entity. ///-/// The addition is purely structural, so the stage is bare `.lightweight` and-/// there is no data pass. The three tables arrive empty; `V11RecordedStoreTests`-/// asserts that on raw fetches.+/// V12 is frozen for the same reason V5 through V11 were: *any* edit to its body+/// makes a V12-recorded store refuse to open with `NSCocoaErrorDomain` 134504,+/// "Cannot use staged migration with an unknown model version". The live classes+/// therefore moved to `AsterismSchemaV13`, and this declaration exists only to+/// give `AsterismV13MigrationPlan` the `from` version of its **only** stage —+/// and to let `V12RecordedStoreFixture` seed a genuinely 12.0.0-recorded store+/// in-process. It is the last snapshot the package declares: the V11 → V12 stage+/// and `AsterismSchemaV11` retired with this freeze, on+/// `retire-migration-chain` Decision 6's population precondition, confirmed by+/// the owner on 2026-09-10 (`place-extraction` Q48). ///-/// The entity list grows from twelve to fifteen.+/// The classes are nested so they can carry the same SwiftData entity names+/// ("Entry", "Site", …) as the live V13 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 V12-shaped object+/// at runtime, so these carry stored columns only — no accessors, no business+/// logic.+///+/// # These snapshots are frozen *by reference*, not only by file+///+/// The nesting freezes the class bodies; it does **not** freeze anything a body+/// *names*. Editing one of those changes the stored shape of this frozen schema+/// silently — and that is exactly what makes a recorded store refuse to open+/// (134504). Two families of referent, both live and both shared with V13:+///+/// * **The stored value types.** `JunkSuffixRule` is a top-level type in+///   `ValueObjects.swift`; its stored properties are this schema's stored+///   properties.+/// * **Every enum whose raw value is baked into a default.** A default is part+///   of the shape, so `CaptureTitleSource.manual.rawValue`,+///   `EntryIdentityBasis.conservative.rawValue`, `TitleProvenance.manual`,+///   `SiteMode.untaught.rawValue`, `URLRuleOrigin.readerTaught`,+///   `WorkTypeState.active`, `CharacterSuppressionKind.candidate`,+///   `CharacterSuppressionStatus.active`, `WorkURLIdentityState.none`,+///   `WorkStatus.ongoing` and `ReadingStatus.reading` are all frozen *spellings*+///   here, not merely frozen references. Renaming a case, or reordering one+///   whose raw value is derived rather than written out, edits this file without+///   touching it.+///+///   **V13 adds no new baked-in raw value to this file.** `Place` defaults to+///   empty strings, an epoch date and a fresh UUID, and `PlaceSuppression`+///   defaults its two enum columns to the *literals* `"candidate"` and+///   `"active"` rather than to a case's `rawValue`, precisely so the next freeze+///   inherits no further frozen spelling — the choice `Creator.stateRaw` made at+///   V12 and kept below. public enum AsterismSchemaV12: VersionedSchema {     public static let versionIdentifier = Schema.Version(12, 0, 0) @@ -26,42 +68,254 @@ public enum AsterismSchemaV12: VersionedSchema {     } } -/// The migration plan: `[V11, V12]`, one lightweight stage.-///-/// The V10 → V11 stage retired here, with `AsterismSchemaV10`,-/// `V10RecordedStoreFixture` and `V10RecordedStoreTests`, on-/// `retire-migration-chain` Decision 6's population precondition: every device-/// was confirmed on marker `"11"` on 2026-09-07 (`work-creators` Q15, the-/// `prerequisites.md` box). Unlike the two bumps before it, the verification-/// landed *before* the freeze rather than a commit after it, so the marker set-/// and the plan have never disagreed at this generation.-///-/// The retirement is **one commit** with the freeze, because a fixture that-/// opens a deleted snapshot does not compile (Q43 of `work-and-reading-status`,-/// Q60 of `series-and-related-works`).-///-/// A store older than V11 fails closed — `NSCocoaErrorDomain` 134504, "Cannot-/// use staged migration with an unknown model version" — and the recovery is the-/// backup archive, which is what `V4RecordedStoreTests` pins.-///-/// The stage is `.lightweight` and purely **adds**. `.custom` is not an option-/// here for the reason it never is: a custom stage would also run inside the-/// share extension, which must never migrate, and the extension is kept out by-/// the marker instead.-///-/// The live stored shape is not a **subset** of the frozen one — V12 adds three-/// whole tables — so `V11RecordedStoreFixture`'s create-seed-save-**release**-/// ordering is the only thing holding SwiftData's global entity registry-/// coherent, together with `make test-core`'s `--no-parallel`-/// (`docs/agent-notes/schema-migration.md`).-public enum AsterismV12MigrationPlan: SchemaMigrationPlan {-    public static var schemas: [any VersionedSchema.Type] {-        [AsterismSchemaV11.self, AsterismSchemaV12.self]-    }--    public static var stages: [MigrationStage] {-        [-            .lightweight(fromVersion: AsterismSchemaV11.self, toVersion: AsterismSchemaV12.self),-        ]+extension AsterismSchemaV12 {+    @Model+    public final class Entry {+        public var id: UUID = UUID()+        public var captureTitle: String = ""+        public var captureTitleSourceRaw: String = CaptureTitleSource.manual.rawValue+        public var rawURLString: String = ""+        public var canonicalURLString: String?+        public var hostname: String = ""+        public var site: Site?+        public var entryIdentityKey: String = ""+        public var conservativeIdentityKey: String = ""+        public var identityBasisRaw: String = EntryIdentityBasis.conservative.rawValue+        public var urlWorkIdentity: String?+        public var chapterSequence: String?+        public var chapterTitle: String?+        public var note: String = ""+        public var ratingRaw: String?+        public var firstCapturedAt: Date = Date(timeIntervalSince1970: 0)+        public var lastSharedAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var work: Work?+        public var intentionallyUnattached: Bool = false+        public var characterExtractionFingerprint: String?+        public var citationsData: Data?++        public init() {}+    }++    @Model+    public final class Work {+        public var id: UUID = UUID()+        public var displayTitle: String = ""+        public var lastParsedTitle: String?+        public var genericNotes: String = ""+        public var workTypeID: UUID?+        public var genreTags: [String] = []+        public var titleProvenanceRaw: String = TitleProvenance.manual.rawValue+        /// V10's three additions. Their property initialisers are the Core Data+        /// attribute defaults the V9 → V10 stage wrote into every existing row,+        /// which is why the two enum spellings are frozen here (see the header).+        public var workStatusRaw: String = WorkStatus.ongoing.rawValue+        public var readingStatusRaw: String = ReadingStatus.reading.rawValue+        public var verdict: String = ""+        /// V11's two additions, both **optional**: nil is "this work is in no+        /// series", so the V10 → V11 stage had no attribute default to write.+        public var seriesID: UUID?+        public var seriesPosition: Double?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var genericNotesExtractionFingerprint: String?+        @Relationship(deleteRule: .nullify, inverse: \Entry.work)+        public var entries: [Entry]?+        @Relationship(deleteRule: .nullify, inverse: \Character.work)+        public var characters: [Character]?+        @Relationship(deleteRule: .nullify, inverse: \CharacterSuppression.work)+        public var characterSuppressions: [CharacterSuppression]?+        @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.work)+        public var siteMemberships: [WorkSiteMembership]? = []++        public init() {}+    }++    @Model+    public final class Site {+        public var hostname: String = ""+        public var displayName: String = ""+        public var modeRaw: String = SiteMode.untaught.rawValue+        @Relationship(deleteRule: .cascade, inverse: \TitlePattern.site)+        public var patterns: [TitlePattern]?+        @Relationship(deleteRule: .cascade, inverse: \URLRulePattern.site)+        public var urlRules: [URLRulePattern]?+        /// Inverse of `Entry.site`, present only because CloudKit requires every+        /// relationship to have one. Internal for the same reason the live class+        /// keeps it internal (Q17): traversing it faults every Entry for a+        /// hostname.+        @Relationship(deleteRule: .nullify, inverse: \Entry.site)+        var entries: [Entry]?+        /// Inverse of `WorkSiteMembership.site`. Same reasoning again.+        @Relationship(deleteRule: .nullify, inverse: \WorkSiteMembership.site)+        var workMemberships: [WorkSiteMembership]?+        public var junkSuffixRule: JunkSuffixRule?++        public init() {}+    }++    @Model+    public final class TitlePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isActive: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var definitionData: Data?+        public var site: Site?++        public init() {}+    }++    @Model+    public final class URLRulePattern {+        public var id: UUID = UUID()+        public var version: Int = 1+        public var isCurrent: Bool = false+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var originRaw: String = URLRuleOrigin.readerTaught.rawValue+        public var definitionData: Data = Data()+        public var site: Site?++        public init() {}+    }++    @Model+    public final class WorkTypeEntity {+        public var id: UUID = UUID()+        public var name: String = ""+        public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var stateRaw: String = WorkTypeState.active.rawValue+        public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var canonicalID: UUID?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class Character {+        public var id: UUID = UUID()+        public var name: String = ""+        public var nameKey: String = ""+        public var aliases: [String] = []+        public var note: String = ""+        public var factsData: Data?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var work: Work?++        public init() {}+    }++    @Model+    public final class CharacterSuppression {+        public var id: UUID = UUID()+        public var work: Work?+        public var kindRaw: String = CharacterSuppressionKind.candidate.rawValue+        public var nameKey: String = ""+        public var sourceKindRaw: String?+        public var sourceEntryID: UUID?+        public var evidence: String?+        public var statusRaw: String = CharacterSuppressionStatus.active.rawValue+        public var actionAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class WorkSiteMembership {+        public var id: UUID = UUID()+        public var hostname: String = ""+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var urlIdentity: String?+        public var urlIdentityStateRaw: String = WorkURLIdentityState.none.rawValue+        public var urlIdentityRuleID: UUID?+        public var workURLString: String?+        public var workID: UUID?+        public var work: Work?+        public var site: Site?++        public init() {}+    }++    @Model+    public final class WorkDistinctPair {+        public var id: UUID = UUID()+        public var lowerWorkID: UUID = UUID()+        public var higherWorkID: UUID = UUID()+        public var recordedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class Series {+        public var id: UUID = UUID()+        public var name: String = ""+        public var notes: String = ""+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class WorkLink {+        public var id: UUID = UUID()+        public var lowerWorkID: UUID = UUID()+        public var higherWorkID: UUID = UUID()+        public var linkType: String = ""+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    /// V12's three additions. `stateRaw` defaults to the **literal** `"active"`+    /// rather than to `CreatorState.active.rawValue`, which is why neither of+    /// the two new enums appears in the header's frozen-spelling list.+    @Model+    public final class Creator {+        public var id: UUID = UUID()+        public var name: String = ""+        public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var notes: String = ""+        public var notesModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var stateRaw: String = "active"+        public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var canonicalID: UUID?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class CreatorRole {+        public var id: UUID = UUID()+        public var name: String = ""+        public var nameModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var position: Int = 0+        public var positionModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var stateRaw: String = "active"+        public var stateModifiedAt: Date = Date(timeIntervalSince1970: 0)+        public var canonicalID: UUID?+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}+    }++    @Model+    public final class WorkCredit {+        public var id: UUID = UUID()+        public var workID: UUID = UUID()+        public var creatorID: UUID = UUID()+        public var roleIDs: [String] = []+        public var createdAt: Date = Date(timeIntervalSince1970: 0)+        public var modifiedAt: Date = Date(timeIntervalSince1970: 0)++        public init() {}     } }
Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift Added +68 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swiftnew file mode 100644index 0000000..bf963c0--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV13.swift@@ -0,0 +1,68 @@+import Foundation+import SwiftData++/// The runtime schema. Its body is `Models.swift`, which opens+/// `extension AsterismSchemaV13`.+///+/// V13 is V12 **plus two new tables** — `Place` and `PlaceSuppression`+/// (`place-extraction`). Nothing else moves: **no `Work` column is added**, no+/// existing column changes type, and no relationship changes shape. It is the+/// second stage in the project's history that adds *only* tables, after V12.+///+/// The addition is purely structural, so the stage is bare `.lightweight` and+/// there is no data pass. The two tables arrive empty; `V12RecordedStoreTests`+/// asserts that on raw fetches.+///+/// The entity list grows from fifteen to seventeen.+public enum AsterismSchemaV13: VersionedSchema {+    public static let versionIdentifier = Schema.Version(13, 0, 0)++    public static var models: [any PersistentModel.Type] {+        [Entry.self, Work.self, Site.self, TitlePattern.self, URLRulePattern.self,+         WorkTypeEntity.self, Character.self, CharacterSuppression.self,+         WorkSiteMembership.self, WorkDistinctPair.self,+         Series.self, WorkLink.self,+         Creator.self, CreatorRole.self, WorkCredit.self,+         Place.self, PlaceSuppression.self]+    }+}++/// The migration plan: `[V12, V13]`, one lightweight stage.+///+/// The V11 → V12 stage retired here, with `AsterismSchemaV11`,+/// `V11RecordedStoreFixture` and `V11RecordedStoreTests`, on+/// `retire-migration-chain` Decision 6's population precondition: every device+/// was confirmed on marker `"12"` on 2026-09-10 (`place-extraction` Q48, the+/// `prerequisites.md` box). As at the previous bump, the verification landed+/// *before* the freeze rather than a commit after it, so the marker set and the+/// plan have never disagreed at this generation.+///+/// The retirement is **one commit** with the freeze, because a fixture that+/// opens a deleted snapshot does not compile (Q43 of `work-and-reading-status`,+/// Q60 of `series-and-related-works`, Q15 of `work-creators`).+///+/// A store older than V12 fails closed — `NSCocoaErrorDomain` 134504, "Cannot+/// use staged migration with an unknown model version" — and the recovery is the+/// backup archive, which is what `V4RecordedStoreTests` pins.+///+/// The stage is `.lightweight` and purely **adds**. `.custom` is not an option+/// here for the reason it never is: a custom stage would also run inside the+/// share extension, which must never migrate, and the extension is kept out by+/// the marker instead.+///+/// The live stored shape is not a **subset** of the frozen one — V13 adds two+/// whole tables — so `V12RecordedStoreFixture`'s create-seed-save-**release**+/// ordering is the only thing holding SwiftData's global entity registry+/// coherent, together with `make test-core`'s `--no-parallel`+/// (`docs/agent-notes/schema-migration.md`).+public enum AsterismV13MigrationPlan: SchemaMigrationPlan {+    public static var schemas: [any VersionedSchema.Type] {+        [AsterismSchemaV12.self, AsterismSchemaV13.self]+    }++    public static var stages: [MigrationStage] {+        [+            .lightweight(fromVersion: AsterismSchemaV12.self, toVersion: AsterismSchemaV13.self),+        ]+    }+}
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift Modified +68 / -64
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swiftindex 23d6635..fa35e36 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveProjection.swift@@ -4,7 +4,7 @@ import SwiftData  private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupExport") -// The record projection every 11/12 export runs through, and the three refusals it+// The record projection every 12/13 export runs through, and the three refusals it // names. It stood in the 4/4 exporter while three generations shared it; // those generations are gone and this is the live export path, so it stands on // its own.@@ -15,14 +15,14 @@ private let exportLogger = Logger(subsystem: "AsterismCore", category: "BackupEx /// the Work mapper, plus the identity groups it needs. internal struct ArchiveCommonProjection {     let groups: BackupGroupProjection.Projection-    let entries: [BackupV11Entry]-    let sites: [BackupV11Site]-    let titlePatterns: [BackupV11TitlePattern]-    let urlRules: [BackupV11URLRule]+    let entries: [BackupV12Entry]+    let sites: [BackupV12Site]+    let titlePatterns: [BackupV12TitlePattern]+    let urlRules: [BackupV12URLRule]     /// One record per Work and hostname (Req 9.1), enumerated whole rather than     /// works→children (Q17): a membership whose Work has not arrived exports     /// naming the Work it belongs to instead of vanishing from the backup.-    let memberships: [BackupV11Membership]+    let memberships: [BackupV12Membership] }  /// Every Entry row's citations, decoded **once** for the whole projection.@@ -84,8 +84,12 @@ extension LibraryRepository {         // character whose work has not arrived is a tolerated in-flight state,         // and a child-of-work walk would drop it out of the backup silently.         let characters = try context.fetch(FetchDescriptor<CharacterRecord>())+        // V13's places, enumerated whole for the same reason and one more: an+        // orphan is reachable *only* this way, `Place` having no inverse at all+        // (Req 5.5).+        let places = try context.fetch(FetchDescriptor<Place>())         let groups = try BackupGroupProjection.project(-            entries: entries, works: works, characters: characters,+            entries: entries, works: works, characters: characters, places: places,             types: workTypeDirectory(context: context),             distinctPairs: DuplicateScan.distinctPairKeys(context: context))         // Req 4.5, Q14: a URL rule whose stored definition will not decode@@ -125,7 +129,7 @@ extension LibraryRepository {         var additionalPatterns: [String: [TitlePattern]] = [:]         for pattern in archivablePatterns where pattern.site == nil {             guard let hostname = citers[pattern.id] else {-                throw BackupV11ExportError.referencesStillArriving(+                throw BackupV12ExportError.referencesStillArriving(                     detail: "title rule \(pattern.id) has no site and no entry naming one")             }             additionalPatterns[hostname, default: []].append(pattern)@@ -133,7 +137,7 @@ extension LibraryRepository {         var additionalURLRules: [String: [URLRulePattern]] = [:]         for rule in archivableURLRules where rule.site == nil {             guard let hostname = citers[rule.id] else {-                throw BackupV11ExportError.referencesStillArriving(+                throw BackupV12ExportError.referencesStillArriving(                     detail: "URL rule \(rule.id) has no site and no record naming one")             }             additionalURLRules[hostname, default: []].append(rule)@@ -160,25 +164,25 @@ extension LibraryRepository {             ruleMembership: .oneRowPerIdentityGroup)         try requireProjectedTuplesRepresentable(projected) -        var wireSites: [BackupV11Site] = []-        var wirePatterns: [BackupV11TitlePattern] = []-        var wireRules: [BackupV11URLRule] = []+        var wireSites: [BackupV12Site] = []+        var wirePatterns: [BackupV12TitlePattern] = []+        var wireRules: [BackupV12URLRule] = []         for site in projected {-            wireSites.append(mapV11SiteRecord(site))+            wireSites.append(mapV12SiteRecord(site))             for projectedPattern in site.patterns             where !omittedTitlePatternIDs.contains(projectedPattern.pattern.id) {                 wirePatterns.append(-                    try mapV11TitlePatternRecord(projectedPattern, hostname: site.hostname))+                    try mapV12TitlePatternRecord(projectedPattern, hostname: site.hostname))             }             for projectedRule in site.urlRules             where !omittedURLRuleIDs.contains(projectedRule.rule.id) {-                wireRules.append(try mapV11URLRuleRecord(projectedRule, hostname: site.hostname))+                wireRules.append(try mapV12URLRuleRecord(projectedRule, hostname: site.hostname))             }         }          return ArchiveCommonProjection(             groups: groups,-            entries: try groups.entries.map { try mapV11EntryRecord($0, citations: citations) },+            entries: try groups.entries.map { try mapV12EntryRecord($0, citations: citations) },             sites: wireSites.sorted { $0.hostname < $1.hostname },             titlePatterns: wirePatterns.sorted { $0.id.uuidString < $1.id.uuidString },             urlRules: wireRules.sorted { $0.id.uuidString < $1.id.uuidString },@@ -221,7 +225,7 @@ extension LibraryRepository {             // than reached by a mapper that would throw a raw `DecodingError`.             do { _ = try citations.value(of: entry) }             catch {-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: record, field: "citations", value: String(describing: error))             }         }@@ -248,22 +252,22 @@ extension LibraryRepository {             case (nil, nil):                 break             case (let id?, nil):-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: record, field: "series membership",                     value: "series \(id) with no position")             case (nil, let position?):-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: record, field: "series membership",                     value: "position \(position) with no series")             case (_?, let position?):                 guard position.isFinite else {-                    throw BackupV11ExportError.unrepresentableValue(+                    throw BackupV12ExportError.unrepresentableValue(                         record: record, field: "series position", value: String(position))                 }                 // Q15: at most one fraction digit. Rounding here would move a                 // reader's value inside their own backup.                 guard position == SeriesPosition.rounded(position) else {-                    throw BackupV11ExportError.unrepresentableValue(+                    throw BackupV12ExportError.unrepresentableValue(                         record: record, field: "series position", value: String(position))                 }             }@@ -290,7 +294,7 @@ extension LibraryRepository {             // `formRaw` that no longer exists.             do { _ = try pattern.storedDefinition }             catch {-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: record, field: "definition", value: String(describing: error))             }         }@@ -328,7 +332,7 @@ extension LibraryRepository {         var omitted: Set<UUID> = []         for (id, rule) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {             guard !cited.contains(id) else {-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: "URL rule \(id)", field: "definition",                     value: "\(rule.definitionData.count) bytes that do not decode")             }@@ -379,7 +383,7 @@ extension LibraryRepository {         var omitted: Set<UUID> = []         for (id, pattern) in unreadable.sorted(by: { $0.key.uuidString < $1.key.uuidString }) {             guard !pattern.isActive, !cited.contains(id) else {-                throw BackupV11ExportError.unrepresentableValue(+                throw BackupV12ExportError.unrepresentableValue(                     record: "Title rule \(id)", field: "definition",                     value: pattern.definitionData.map { "\($0.count) bytes that do not decode" }                         ?? "no stored definition")@@ -435,7 +439,7 @@ extension LibraryRepository {         _ value: Value?, _ record: String, _ field: String, _ raw: String     ) throws {         guard value == nil else { return }-        throw BackupV11ExportError.unrepresentableValue(record: record, field: field, value: raw)+        throw BackupV12ExportError.unrepresentableValue(record: record, field: field, value: raw)     }      /// Req 3.7's third face: a hostname whose *projected* tuple the archive@@ -461,7 +465,7 @@ extension LibraryRepository {             switch site.mode {             case .taught:                 guard activePatterns != 1 else { continue }-                throw BackupV11ExportError.referencesStillArriving(+                throw BackupV12ExportError.referencesStillArriving(                     detail: "site \(site.hostname) is taught, and the one active title rule "                         + "that state needs is not in the library")             case .untaught:@@ -469,12 +473,12 @@ extension LibraryRepository {                     $0.rule.origin == .importedV2 && !$0.isCurrent                 }                 guard !site.patterns.isEmpty || currentRules > 0 || !historyOnly else { continue }-                throw BackupV11ExportError.referencesStillArriving(+                throw BackupV12ExportError.referencesStillArriving(                     detail: "site \(site.hostname) is untaught while still holding rules, "                         + "so the teaching that owns them has not arrived")             case .articles:                 guard activePatterns > 0 || currentRules > 0 else { continue }-                throw BackupV11ExportError.referencesStillArriving(+                throw BackupV12ExportError.referencesStillArriving(                     detail: "site \(site.hostname) reads as articles while still holding an "                         + "active rule, so the change that cleared them has not arrived")             }@@ -495,10 +499,10 @@ extension LibraryRepository {     /// problem surfacing as a broken file, which is exactly what this gate     /// exists to say first.     internal static func requireCitationsResolve(-        entries: [BackupV11Entry],-        memberships: [BackupV11Membership],-        titlePatterns: [BackupV11TitlePattern],-        urlRules: [BackupV11URLRule]+        entries: [BackupV12Entry],+        memberships: [BackupV12Membership],+        titlePatterns: [BackupV12TitlePattern],+        urlRules: [BackupV12URLRule]     ) throws {         let rulesByID = Dictionary(urlRules.map { ($0.id, $0) }, uniquingKeysWith: { lhs, _ in lhs })         let patternHostnames = Dictionary(@@ -538,14 +542,14 @@ extension LibraryRepository {      private static func crossSiteCitation(         _ record: String, _ field: String, taughtFor hostname: String-    ) -> BackupV11ExportError {+    ) -> BackupV12ExportError {         .referencesStillArriving(             detail: "\(record) names \(field), which is taught for \(hostname)")     }      private static func missingCitation(         _ record: String, _ field: String-    ) -> BackupV11ExportError {+    ) -> BackupV12ExportError {         .referencesStillArriving(             detail: "\(record) names \(field), which the library does not hold")     }@@ -573,7 +577,7 @@ extension LibraryRepository {         return map     } -    // MARK: - V11 Record Mappers+    // MARK: - V12 Record Mappers      /// The record an Entry identity group archives as (Req 8.2): the     /// representative row's capture evidence, the **group's** authored content,@@ -592,9 +596,9 @@ extension LibraryRepository {     /// a citation of a rule the projection renumbered was archived at the     /// version the archive actually held (Decision 7). A citation is a UUID     /// (T-2281) and nothing renumbers, so the map and the parameter are gone.-    internal static func mapV11EntryRecord(+    internal static func mapV12EntryRecord(         _ group: EntryGroup, citations cache: EntryCitationsCache-    ) throws -> BackupV11Entry {+    ) throws -> BackupV12Entry {         let snap = try snapshot(group)         let entry = group.representative         let carrier = group.carrier@@ -604,7 +608,7 @@ extension LibraryRepository {             citations.chapterTitle = carried.chapterTitle             citations.workAssignment = carried.workAssignment         }-        return BackupV11Entry(+        return BackupV12Entry(             id: snap.id,             captureTitle: snap.captureTitle,             captureTitleSource: snap.captureTitleSource,@@ -655,18 +659,18 @@ extension LibraryRepository {     /// archive record carries. Fetching and folding the whole `Series` table     /// once per export to compose qualifiers nothing reads is the one thing this     /// mapper does not need.-    internal static func mapV11WorkRecord(+    internal static func mapV12WorkRecord(         _ group: WorkGroup,         canonicalWorkIDs: [UUID: UUID],         types: WorkTypeDirectory-    ) throws -> BackupV11Work {+    ) throws -> BackupV12Work {         let snap = try snapshot(             group, canonicalWorkIDs: canonicalWorkIDs, types: types, series: .empty,             // An **empty** credit index for the empty directory's reason: this             // mapper reads the `Work` record's own fields, and a credit is a             // record of its own that the archive projects beside them             // (`work-creators` Decision 6). Folding the whole credit table per-            // exported work to fill a field no `BackupV11Work` carries is a read+            // exported work to fill a field no `BackupV12Work` carries is a read             // this path skips.             credits: .empty)         let assignment = WorkTypeAssignment.assignment(of: group.carrier)@@ -678,7 +682,7 @@ extension LibraryRepository {         case .configured(let id):             (workTypeID, typeName) = (id, types.resolve(id)?.name)         }-        return BackupV11Work(+        return BackupV12Work(             id: snap.id,             displayTitle: snap.displayTitle,             lastParsedTitle: snap.lastParsedTitle,@@ -721,7 +725,7 @@ extension LibraryRepository {     /// there.     private static func mapMembershipRecords(         _ rows: [WorkSiteMembership]-    ) -> [BackupV11Membership] {+    ) -> [BackupV12Membership] {         var byKey: [MembershipReconciler.Key: [WorkSiteMembership]] = [:]         var unattributed: [WorkSiteMembership] = []         for row in rows {@@ -732,8 +736,8 @@ extension LibraryRepository {             byKey[MembershipReconciler.Key(workID: workID, hostname: row.hostname), default: []]                 .append(row)         }-        func record(_ row: WorkSiteMembership, workURLString: String?) -> BackupV11Membership {-            BackupV11Membership(+        func record(_ row: WorkSiteMembership, workURLString: String?) -> BackupV12Membership {+            BackupV12Membership(                 id: row.id, workID: row.resolvedWorkID, hostname: row.hostname,                 createdAt: row.createdAt, urlIdentity: row.urlIdentity,                 urlIdentityState: row.urlIdentityState,@@ -748,7 +752,7 @@ extension LibraryRepository {         // [4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)         // cannot survive. Nothing is written back: the fold returns the value         // and the record carries it (Q54).-        var records: [BackupV11Membership] = []+        var records: [BackupV12Membership] = []         for rows in byKey.values {             let ordered = MembershipReconciler.survivorFirst(rows)             guard let keeper = ordered.first else { continue }@@ -786,7 +790,7 @@ extension LibraryRepository {     /// clauses are this projection's own: the directory's tie-break is the     /// identifier, which two rows of one series share, and a tie the fetch order     /// broke would put two devices' archives one byte apart.-    internal static func projectSeries(context: ModelContext) throws -> [BackupV11Series] {+    internal static func projectSeries(context: ModelContext) throws -> [BackupV12Series] {         var byID: [UUID: Series] = [:]         for row in try context.fetch(FetchDescriptor<Series>()) {             guard let held = byID[row.id] else {@@ -797,7 +801,7 @@ extension LibraryRepository {         }         return byID.values             .map {-                BackupV11Series(+                BackupV12Series(                     id: $0.id, name: $0.name, notes: $0.notes,                     createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)             }@@ -819,7 +823,7 @@ extension LibraryRepository {     /// a self-naming pair is — Req 6.1 forbids a link from a work to itself, so     /// such a row is not a link a reader can have meant, and `dedupeLinks`     /// deletes it too.-    internal static func projectLinks(context: ModelContext) throws -> [BackupV11Link] {+    internal static func projectLinks(context: ModelContext) throws -> [BackupV12Link] {         var byKey: [WorkPairKey: [WorkLink]] = [:]         for row in try context.fetch(FetchDescriptor<WorkLink>())         where row.lowerWorkID != row.higherWorkID {@@ -829,7 +833,7 @@ extension LibraryRepository {             guard let survivor = MembershipReconciler.survivorFirstLinks(rows).first else {                 return nil             }-            return BackupV11Link(+            return BackupV12Link(                 id: survivor.id, lowerWorkID: key.lower, higherWorkID: key.higher,                 linkType: survivor.linkType, createdAt: survivor.createdAt,                 modifiedAt: survivor.modifiedAt)@@ -861,7 +865,7 @@ extension LibraryRepository {     /// the same number the next local pass would.     internal static func projectCredits(         context: ModelContext, creators: CreatorDirectory-    ) throws -> [BackupV11Credit] {+    ) throws -> [BackupV12Credit] {         var byKey: [CreditReconciler.Key: [WorkCredit]] = [:]         for row in try context.fetch(FetchDescriptor<WorkCredit>()) {             let key = CreditReconciler.Key(@@ -871,7 +875,7 @@ extension LibraryRepository {         return byKey.compactMap { _, rows in             let ordered = WorkCreditSupport.survivorFirstCredits(rows)             guard let head = ordered.first else { return nil }-            return BackupV11Credit(+            return BackupV12Credit(                 id: head.id, workID: head.workID, creatorID: head.creatorID,                 roleIDs: WorkCreditSupport.roleIDs(ordered.flatMap(\.roleIDs)),                 createdAt: head.createdAt,@@ -882,7 +886,7 @@ extension LibraryRepository {      internal static func projectDistinctPairs(         context: ModelContext-    ) throws -> [BackupV11DistinctPair] {+    ) throws -> [BackupV12DistinctPair] {         var byKey: [WorkPairKey: [WorkDistinctPair]] = [:]         for row in try context.fetch(FetchDescriptor<WorkDistinctPair>())         where row.lowerWorkID != row.higherWorkID {@@ -894,7 +898,7 @@ extension LibraryRepository {             guard let survivor = MembershipReconciler.survivorFirstPairs(rows).first else {                 return nil             }-            return BackupV11DistinctPair(+            return BackupV12DistinctPair(                 id: survivor.id, lowerWorkID: key.lower, higherWorkID: key.higher,                 recordedAt: survivor.recordedAt)         }@@ -904,10 +908,10 @@ extension LibraryRepository {     /// The wire Site for a hostname: exactly one, whatever the store holds     /// (Q38). It names no children (Req 9.3) — the union still decides which     /// rules are archived, and each of them names this hostname back.-    internal static func mapV11SiteRecord(+    internal static func mapV12SiteRecord(         _ projected: SiteUnionProjection.ProjectedSite-    ) -> BackupV11Site {-        BackupV11Site(+    ) -> BackupV12Site {+        BackupV12Site(             hostname: projected.hostname,             displayName: projected.displayName,             mode: projected.mode,@@ -915,10 +919,10 @@ extension LibraryRepository {         )     } -    internal static func mapV11TitlePatternRecord(+    internal static func mapV12TitlePatternRecord(         _ projected: SiteUnionProjection.ProjectedTitlePattern, hostname: String-    ) throws -> BackupV11TitlePattern {-        BackupV11TitlePattern(+    ) throws -> BackupV12TitlePattern {+        BackupV12TitlePattern(             id: projected.pattern.id,             siteHostname: hostname,             // The version stored on the row the per-UUID reduction kept, without@@ -933,17 +937,17 @@ extension LibraryRepository {     /// `throws` because the definition does (Req 4.5): the mapper needs a typed     /// `URLRuleDefinition` for every row it writes, so a row that will not     /// decode cannot be archived at all.-    internal static func mapV11URLRuleRecord(+    internal static func mapV12URLRuleRecord(         _ projected: SiteUnionProjection.ProjectedURLRule, hostname: String-    ) throws -> BackupV11URLRule {+    ) throws -> BackupV12URLRule {         let definition: URLRuleDefinition         do { definition = try projected.rule.definition }         catch {-            throw BackupV11ExportError.unrepresentableValue(+            throw BackupV12ExportError.unrepresentableValue(                 record: "URL rule \(projected.rule.id)", field: "definition",                 value: "\(projected.rule.definitionData.count) bytes that do not decode")         }-        return BackupV11URLRule(+        return BackupV12URLRule(             id: projected.rule.id,             version: projected.rule.version,             isCurrent: projected.isCurrent,
Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift Modified +25 / -25
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swiftindex 962d82e..d353acb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupArchiveReferenceChecks.swift@@ -24,18 +24,18 @@ internal enum BackupArchiveReferenceChecks {     ///   refusal — the one message here that has to say which archive format it     ///   is talking about.     static func validate(-        entries: [BackupV11Entry],-        works: [BackupV11Work],-        memberships: [BackupV11Membership],-        distinctPairs: [BackupV11DistinctPair],-        sites: [BackupV11Site],-        titlePatterns: [BackupV11TitlePattern],-        urlRules: [BackupV11URLRule],-        series: [BackupV11Series],-        links: [BackupV11Link],-        creators: [BackupV11Creator],-        creatorRoles: [BackupV11CreatorRole],-        credits: [BackupV11Credit],+        entries: [BackupV12Entry],+        works: [BackupV12Work],+        memberships: [BackupV12Membership],+        distinctPairs: [BackupV12DistinctPair],+        sites: [BackupV12Site],+        titlePatterns: [BackupV12TitlePattern],+        urlRules: [BackupV12URLRule],+        series: [BackupV12Series],+        links: [BackupV12Link],+        creators: [BackupV12Creator],+        creatorRoles: [BackupV12CreatorRole],+        credits: [BackupV12Credit],         formatLabel: String     ) throws {         let siteHostnames = Set(sites.map(\.hostname))@@ -306,9 +306,9 @@ internal enum BackupArchiveReferenceChecks {     // MARK: Site closed tuple (supersedes M3 8.1)      private static func validateSiteTuple(-        _ site: BackupV11Site,-        patterns: [BackupV11TitlePattern],-        rules: [BackupV11URLRule]+        _ site: BackupV12Site,+        patterns: [BackupV12TitlePattern],+        rules: [BackupV12URLRule]     ) throws {         let id = site.hostname         guard !M2Unicode.isBlank(site.hostname) else { throw invalid("Site", id, "hostname is blank") }@@ -373,9 +373,9 @@ internal enum BackupArchiveReferenceChecks {     /// (Req 9.5, Q22); its own tuple is still checked, because an orphan is a     /// row like any other.     private static func validateMembership(-        _ membership: BackupV11Membership,+        _ membership: BackupV12Membership,         siteHostnames: Set<String>,-        rulesByID: [UUID: BackupV11URLRule]+        rulesByID: [UUID: BackupV12URLRule]     ) throws {         let id = membership.id.uuidString         guard !M2Unicode.isBlank(membership.hostname) else {@@ -404,12 +404,12 @@ internal enum BackupArchiveReferenceChecks {     // MARK: Entry (Entry-state enumeration, supersedes M3 8.12)      private static func validateEntry(-        _ entry: BackupV11Entry,+        _ entry: BackupV12Entry,         siteHostnames: Set<String>,         workIDs: Set<UUID>,         hostnamesByWork: [UUID: Set<String>],-        patternsByID: [UUID: BackupV11TitlePattern],-        rulesByID: [UUID: BackupV11URLRule]+        patternsByID: [UUID: BackupV12TitlePattern],+        rulesByID: [UUID: BackupV12URLRule]     ) throws {         let id = entry.id.uuidString         guard siteHostnames.contains(entry.hostname) else {@@ -490,15 +490,15 @@ internal enum BackupArchiveReferenceChecks {     /// dangling citation is an unresolved reference — so the predicate is stated     /// once and each caller names its own failure.     private static func resolvesSameSite(-        _ cited: CitedRule, entry: BackupV11Entry, rulesByID: [UUID: BackupV11URLRule]+        _ cited: CitedRule, entry: BackupV12Entry, rulesByID: [UUID: BackupV12URLRule]     ) -> Bool {         rulesByID[cited.id]?.siteHostname == entry.hostname     }      private static func requireSameSiteRule(         _ cited: CitedRule,-        entry: BackupV11Entry,-        rulesByID: [UUID: BackupV11URLRule]+        entry: BackupV12Entry,+        rulesByID: [UUID: BackupV12URLRule]     ) throws {         guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else {             throw invalid(@@ -508,10 +508,10 @@ internal enum BackupArchiveReferenceChecks {     }      private static func validateEntryRuleReference(-        _ entry: BackupV11Entry,+        _ entry: BackupV12Entry,         field: String,         cited: CitedRule?,-        rulesByID: [UUID: BackupV11URLRule]+        rulesByID: [UUID: BackupV12URLRule]     ) throws {         guard let cited else { return }         guard resolvesSameSite(cited, entry: entry, rulesByID: rulesByID) else {
Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swiftindex 4648ae8..d35c449 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupExporter.swift@@ -1,7 +1,7 @@ import Foundation  /// The file a completed backup export produced, handed to the share sheet and-/// cleaned up afterwards. `BackupV11Exporter` is the only producer.+/// cleaned up afterwards. `BackupV12Exporter` is the only producer. public struct BackupExportResult: Sendable {     public let fileURL: URL 
Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift Modified +22 / -12
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swiftindex 2ae4e45..111515e 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift@@ -70,9 +70,13 @@ enum BackupGroupProjection {         /// 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]+        /// Every place in the store, as logical records, on the same terms.+        /// Read by the 12/13 payload; the 11/12 one had nowhere to write them,+        /// and the torn refusal below applies to every format regardless.+        let places: [RecordGroup<Place>]     } -    /// - Throws: `BackupV11ExportError.tornGroups` when the store holds a torn+    /// - Throws: `BackupV12ExportError.tornGroups` when the store holds a torn     ///   group — the biconditional of Req 8.1, since nothing else here refuses.     /// - Parameter distinctPairs: the reader's recorded "not the same work"     ///   dismissals (Req 5.6). **Not defaulted**: an export that cannot see them@@ -80,6 +84,7 @@ enum BackupGroupProjection {     ///   a group every screen in the app shows as whole.     static func project(         entries: [Entry], works: [Work], characters: [CharacterRecord] = [],+        places: [Place] = [],         types: WorkTypeDirectory, distinctPairs: Set<WorkPairKey>     ) throws -> Projection {         // The Definitions' assignment normalisation (Q106): an Entry group whose@@ -93,20 +98,24 @@ enum BackupGroupProjection {         let entryGroups = LibraryRepository.entryGroups(             entries, canonicalWorkIDs: canonicalWorkIDs)         let workGroups = LibraryRepository.workGroups(works, types: types)-        let characterGroups = LibraryRepository.characterGroups(characters)+        let characterGroups = LibraryRepository.recordGroups(characters)+        let placeGroups = LibraryRepository.recordGroups(places)          let tornEntries = entryGroups.values.filter(\.isTorn)         let tornWorks = workGroups.values.filter(\.isTorn)-        // Req 6.5: a torn character group is the same thing an archive cannot+        // Req 6.5: a torn record 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.+        // no site at all and a torn record would export one variant silently.+        // Both kinds, for the same reason.         let tornCharacters = characterGroups.values.filter(\.isTorn)-        guard tornEntries.isEmpty, tornWorks.isEmpty, tornCharacters.isEmpty else {-            throw BackupV11ExportError.tornGroups(+        let tornPlaces = placeGroups.values.filter(\.isTorn)+        guard tornEntries.isEmpty, tornWorks.isEmpty, tornCharacters.isEmpty, tornPlaces.isEmpty+        else {+            throw BackupV12ExportError.tornGroups(                 tornGroupsPayload(                     tornEntries: tornEntries, tornWorks: tornWorks,-                    tornCharacters: tornCharacters,+                    tornRecordCount: tornCharacters.count + tornPlaces.count,                     entries: entries, workSets: workSets))         } @@ -115,7 +124,8 @@ enum BackupGroupProjection {             types: types,             entries: entryGroups.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 })+            characters: characterGroups.values.sorted { $0.id.uuidString < $1.id.uuidString },+            places: placeGroups.values.sorted { $0.id.uuidString < $1.id.uuidString })     }      /// Req 8.4's second arm. The blocking Work set is named only when every torn@@ -125,15 +135,15 @@ enum BackupGroupProjection {     private static func tornGroupsPayload(         tornEntries: [EntryGroup],         tornWorks: [WorkGroup],-        tornCharacters: [CharacterGroup],+        tornRecordCount: Int,         entries: [Entry],         workSets: [WorkDuplicateSet]     ) -> TornGroupsPayload {-        let count = tornEntries.count + tornWorks.count + tornCharacters.count-        // A torn character waits behind nothing — its set has one member and no+        let count = tornEntries.count + tornWorks.count + tornRecordCount+        // A torn record 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 {+        guard tornWorks.isEmpty, tornRecordCount == 0 else {             return TornGroupsPayload(count: count, blockingWorkSet: nil)         }         let entrySets = DuplicateScan.entrySets(of: entries, workSets: workSets)
Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift Deleted +0 / -138
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swiftdeleted file mode 100644index 11a5c37..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCharacters.swift+++ /dev/null@@ -1,138 +0,0 @@-import Foundation-import SwiftData--// The character half of an archive import (`character-extraction` 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: BackupImportPayload,-        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 = ArchiveRecordBuilders.makeCharacter(record)-                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 = ArchiveRecordBuilders.makeSuppression(record)-                context.insert(row)-                row.work = target-                suppressionRows[record.id] = [row]-            }-        }--        applyImportedCoverage(payload, workRows: workRows, entryRows: entryRows)-    }--    /// Q81's self-validation, over every row of the addressed group.-    ///-    /// The coverage table is gone (Req 9.4): a fingerprint rides on the record-    /// whose text it describes, so there is no discriminator to switch on and no-    /// pair that can name a record the archive does not carry. The rule it is-    /// applied under is unchanged — the pair is kept exactly where the archived-    /// fingerprint still describes the source's *current* text, and dropped-    /// otherwise.-    private static func applyImportedCoverage(-        _ payload: BackupImportPayload,-        workRows: [UUID: [Work]],-        entryRows: [UUID: [Entry]]-    ) {-        for record in payload.works {-            guard let fingerprint = record.genericNotesExtractionFingerprint else { continue }-            for row in workRows[record.id] ?? []-            where CharacterCoverageFingerprint.of(row.genericNotes) == fingerprint {-                row.genericNotesExtractionFingerprint = fingerprint-            }-        }-        for record in payload.entries {-            guard let fingerprint = record.characterExtractionFingerprint else { continue }-            for row in entryRows[record.id] ?? []-            where CharacterCoverageFingerprint.of(row.note) == fingerprint {-                row.characterExtractionFingerprint = fingerprint-            }-        }-    }--    /// 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: BackupV11Character, 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: BackupV11Suppression, 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/BackupImportCreators.swift Modified +6 / -6
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swiftindex a88b7b3..28868f9 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportCreators.swift@@ -34,8 +34,8 @@ extension LibraryRepository {     ///   of which are the reader acting *now* and have to assert over what is     ///   already there.     internal static func mergeImportedCreatorDirectories(-        creators: [BackupV11Creator],-        creatorRoles: [BackupV11CreatorRole],+        creators: [BackupV12Creator],+        creatorRoles: [BackupV12CreatorRole],         importedAt: Date,         context: ModelContext,         saveStrategy: any RepositorySaveStrategy@@ -72,7 +72,7 @@ extension LibraryRepository {      /// - Returns: whether anything was written.     private static func mergeImportedCreators(-        _ records: [BackupV11Creator], context: ModelContext+        _ records: [BackupV12Creator], context: ModelContext     ) throws -> Bool {         guard !records.isEmpty else { return false }         let rows = try context.fetch(FetchDescriptor<Creator>())@@ -149,7 +149,7 @@ extension LibraryRepository {     /// that is absent or itself merged; anything the checks tolerate and this     /// does not answer for leaves the local record as it stands.     private static func answersForACreator(-        _ survivor: UUID, local: CreatorDirectory, archive: [UUID: BackupV11Creator]+        _ survivor: UUID, local: CreatorDirectory, archive: [UUID: BackupV12Creator]     ) -> Bool {         if let record = archive[survivor],             ToleratedEnum.read(record.stateRaw, default: CreatorState.active) != .merged@@ -172,7 +172,7 @@ extension LibraryRepository {     /// sides follows the per-field rule either way, so an order the archive     /// recorded later than the local one still wins.     private static func mergeImportedCreatorRoles(-        _ records: [BackupV11CreatorRole], importedAt: Date, context: ModelContext+        _ records: [BackupV12CreatorRole], importedAt: Date, context: ModelContext     ) throws -> Bool {         guard !records.isEmpty else { return false }         let rows = try context.fetch(FetchDescriptor<CreatorRole>())@@ -257,7 +257,7 @@ extension LibraryRepository {      /// `answersForACreator` over the role table.     private static func answersForARole(-        _ survivor: UUID, local: CreatorRoleDirectory, archive: [UUID: BackupV11CreatorRole]+        _ survivor: UUID, local: CreatorRoleDirectory, archive: [UUID: BackupV12CreatorRole]     ) -> Bool {         if let record = archive[survivor],             ToleratedEnum.read(record.stateRaw, default: CreatorRoleState.active) != .merged
Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift Added +253 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swiftnew file mode 100644index 0000000..618ddcd--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportRecords.swift@@ -0,0 +1,253 @@+import Foundation+import SwiftData++// The record half of an archive import (`character-extraction` Req 6.1,+// `place-extraction` Req 5.1).+//+// **Additive, UUID-keyed, and never a deletion** — the upsert's posture, applied+// to the arrays that carry the reader's named records:+//+// * a record 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.+//+// **One body, called once per kind** (Decision 3 of `place-extraction`). The two+// record tables differ in how they are fetched, how a new row is built and how+// ownership is applied, and all three of those are the conformance's+// (`RecordRow`, Q60). What is left over — match, guard, fan out to every row of+// the group — is the same sentence twice, so it is written once.++/// The wire shape both record kinds share, so one merge body can read either.+///+/// It is deliberately a *reading* protocol: the archive records stay frozen+/// `Codable` structs with `let` fields, and this names the values the merge+/// needs from one. `archivedWorkID` is where the two differ — a character may+/// carry no owner at all, a place carries one that may resolve to nothing+/// (Q60) — and it is optional here so the one body can ask the same question of+/// both.+internal protocol ArchivedRecord {+    var recordID: UUID { get }+    var archivedWorkID: UUID? { get }+    var name: String { get }+    var nameKey: String { get }+    var aliases: [String] { get }+    var note: String { get }+    var facts: [RecordFact] { get }+    var createdAt: Date { get }+    var modifiedAt: Date { get }+}++/// The suppression counterpart, on the same terms.+internal protocol ArchivedSuppression {+    var recordID: UUID { get }+    var archivedWorkID: UUID? { get }+    var kindRaw: String { get }+    var nameKey: String { get }+    var sourceKindRaw: String? { get }+    var sourceEntryID: UUID? { get }+    var evidence: String? { get }+    var statusRaw: String { get }+    var actionAt: Date { get }+}++extension BackupV12Character: ArchivedRecord {+    var recordID: UUID { id }+    var archivedWorkID: UUID? { workID }+}++extension BackupV12Place: ArchivedRecord {+    var recordID: UUID { id }+    var archivedWorkID: UUID? { workID }+}++extension BackupV12Suppression: ArchivedSuppression {+    var recordID: UUID { id }+    var archivedWorkID: UUID? { workID }+}++extension BackupV12PlaceSuppression: ArchivedSuppression {+    var recordID: UUID { id }+    var archivedWorkID: UUID? { workID }+}++extension LibraryRepository {++    /// Merges an archive's records, 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 record 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 mergeImportedRecords(+        _ payload: BackupImportPayload,+        workTargets: [UUID: Work],+        workRows: [UUID: [Work]],+        entryRows: [UUID: [Entry]],+        context: ModelContext+    ) throws {+        try mergeImportedRecords(+            payload.characters, into: CharacterRecord.self,+            workTargets: workTargets, context: context)+        try mergeImportedSuppressions(+            payload.suppressions, into: CharacterSuppression.self,+            workTargets: workTargets, context: context)+        try mergeImportedRecords(+            payload.places, into: Place.self, workTargets: workTargets, context: context)+        try mergeImportedSuppressions(+            payload.placeSuppressions, into: PlaceSuppression.self,+            workTargets: workTargets, context: context)++        applyImportedCoverage(payload, workRows: workRows, entryRows: entryRows)+    }++    /// One record array into one table.+    ///+    /// The whole-table fetch is deliberate and matches the Work and Entry steps:+    /// an import is a bulk path, and a per-record predicate would be one round+    /// trip per archived record.+    private static func mergeImportedRecords<Row: RecordRow>(+        _ records: [Row.ArchiveRecord],+        into type: Row.Type,+        workTargets: [UUID: Work],+        context: ModelContext+    ) throws where Row.ArchiveRecord: ArchivedRecord {+        guard !records.isEmpty else { return }+        var rowsByID = Dictionary(+            grouping: try context.fetch(FetchDescriptor<Row>()), by: \.recordID)+        for record in records {+            let target = record.archivedWorkID.flatMap { workTargets[$0] }+            if let rows = rowsByID[record.recordID], !rows.isEmpty {+                guard let group = recordGroup(id: record.recordID, 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)+                    // The **update** path hands the resolution map down: a row+                    // that already has an owner this library holds keeps it,+                    // whatever the archive names (`RecordRow`'s `Place.attach`).+                    row.attach(+                        to: target, archivedWorkID: record.archivedWorkID,+                        workTargets: workTargets)+                }+            } else {+                // The insert path has no owner to protect — the row was built+                // from the archive a line ago — so it takes the archived id as+                // it always has, and the orphan round trip stands.+                let row = Row.make(imported: record)+                context.insert(row)+                row.attach(to: target, archivedWorkID: record.archivedWorkID)+                rowsByID[record.recordID] = [row]+            }+        }+    }++    /// One suppression array into one table. Keyed by **row** UUID rather than+    /// by group, because a suppression is a system record: it never tears, and+    /// the store reads a set of them through by `actionAt` (Q82).+    private static func mergeImportedSuppressions<Row: SuppressionRow>(+        _ records: [Row.ArchiveRecord],+        into type: Row.Type,+        workTargets: [UUID: Work],+        context: ModelContext+    ) throws where Row.ArchiveRecord: ArchivedSuppression {+        guard !records.isEmpty else { return }+        var rowsByID = Dictionary(+            grouping: try context.fetch(FetchDescriptor<Row>()), by: \.recordID)+        for record in records {+            let target = record.archivedWorkID.flatMap { workTargets[$0] }+            if let rows = rowsByID[record.recordID], !rows.isEmpty {+                for row in rows where record.actionAt >= row.actionAt {+                    apply(record, to: row)+                    // The **update** path hands the resolution map down, for the+                    // record path's reason (Q76): a suppression that already+                    // sits on a work this library holds keeps it, whatever the+                    // archive names, because a suppression moved off its work+                    // stops suppressing anything.+                    row.attach(+                        to: target, archivedWorkID: record.archivedWorkID,+                        workTargets: workTargets)+                }+            } else {+                let row = Row.make(imported: record)+                context.insert(row)+                row.attach(to: target, archivedWorkID: record.archivedWorkID)+                rowsByID[record.recordID] = [row]+            }+        }+    }++    /// Q81's self-validation, over every row of the addressed group.+    ///+    /// The coverage table is gone (Req 9.4): a fingerprint rides on the record+    /// whose text it describes, so there is no discriminator to switch on and no+    /// pair that can name a record the archive does not carry. The rule it is+    /// applied under is unchanged — the pair is kept exactly where the archived+    /// fingerprint still describes the source's *current* text, and dropped+    /// otherwise.+    private static func applyImportedCoverage(+        _ payload: BackupImportPayload,+        workRows: [UUID: [Work]],+        entryRows: [UUID: [Entry]]+    ) {+        for record in payload.works {+            guard let fingerprint = record.genericNotesExtractionFingerprint else { continue }+            for row in workRows[record.id] ?? []+            where CharacterCoverageFingerprint.of(row.genericNotes) == fingerprint {+                row.genericNotesExtractionFingerprint = fingerprint+            }+        }+        for record in payload.entries {+            guard let fingerprint = record.characterExtractionFingerprint else { continue }+            for row in entryRows[record.id] ?? []+            where CharacterCoverageFingerprint.of(row.note) == fingerprint {+                row.characterExtractionFingerprint = fingerprint+            }+        }+    }++    /// The mutable half of an archived record, 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 record an archive restored. Ownership is **not** written here — it+    /// is `attach(to:archivedWorkID:)`'s, which the two tables answer+    /// differently (Q60).+    internal static func apply<Row: RecordRow>(_ record: some ArchivedRecord, to row: Row) {+        row.name = record.name+        row.nameKey = record.nameKey+        row.aliases = record.aliases+        row.note = record.note+        row.facts = record.facts+        row.createdAt = record.createdAt+        row.modifiedAt = record.modifiedAt+    }++    internal static func apply<Row: SuppressionRow>(+        _ record: some ArchivedSuppression, to row: Row+    ) {+        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 +4 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swiftindex 0e2c938..3a56740 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImportWorkTypes.swift@@ -31,8 +31,8 @@ extension LibraryRepository {     ///     restore of an entry this library already held. That one write is the     ///     reader acting now, and it has to assert over a removal older than it.     internal static func mergeImportedWorkTypes(-        workTypes: [BackupV11WorkType],-        works: [BackupV11Work],+        workTypes: [BackupV12WorkType],+        works: [BackupV12Work],         exportedAt: Date,         importedAt: Date,         context: ModelContext,@@ -171,7 +171,7 @@ extension LibraryRepository {     /// Returned in identifier order, so an interrupted import resumes into the     /// same shape on any device.     private static func archivedTypeIdentities(-        _ records: [BackupV11WorkType]+        _ records: [BackupV12WorkType]     ) -> [ArchivedTypeIdentity] {         let directory = WorkTypeDirectory(             rows: records.map {@@ -266,7 +266,7 @@ extension LibraryRepository {     /// A citation with no snapshot is deliberately absent: it stays on the work     /// as unresolved rather than being invented a name (Q24).     private static func unresolvedTypeCitations(-        _ works: [BackupV11Work], in local: WorkTypeDirectory+        _ works: [BackupV12Work], in local: WorkTypeDirectory     ) -> [ArchivedTypeCitation] {         var seen: Set<UUID> = []         var citations: [ArchivedTypeCitation] = []
Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift Modified +48 / -41
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swiftindex 342ed04..c0b5305 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupImporter.swift@@ -11,43 +11,47 @@ import OSLog /// longer exist and every accessor answered the same arm three times. What /// remains is the payload's arrays, named. ///-/// This is `BackupV11Payload`'s content rather than the type itself: the wire+/// This is `BackupV12Payload`'s content rather than the type itself: the wire /// struct is a `Codable` frozen shape and the plan is what the commit reads, and /// keeping them separate is what lets a future generation arrive without the /// upsert learning its envelope. public struct BackupImportPayload: Sendable, Equatable {-    public let entries: [BackupV11Entry]-    public let works: [BackupV11Work]-    public let sites: [BackupV11Site]-    public let titlePatterns: [BackupV11TitlePattern]-    public let urlRules: [BackupV11URLRule]-    public let workTypes: [BackupV11WorkType]-    public let memberships: [BackupV11Membership]-    public let distinctPairs: [BackupV11DistinctPair]-    public let characters: [BackupV11Character]-    public let suppressions: [BackupV11Suppression]-    public let series: [BackupV11Series]-    public let links: [BackupV11Link]-    public let creators: [BackupV11Creator]-    public let creatorRoles: [BackupV11CreatorRole]-    public let credits: [BackupV11Credit]+    public let entries: [BackupV12Entry]+    public let works: [BackupV12Work]+    public let sites: [BackupV12Site]+    public let titlePatterns: [BackupV12TitlePattern]+    public let urlRules: [BackupV12URLRule]+    public let workTypes: [BackupV12WorkType]+    public let memberships: [BackupV12Membership]+    public let distinctPairs: [BackupV12DistinctPair]+    public let characters: [BackupV12Character]+    public let suppressions: [BackupV12Suppression]+    public let places: [BackupV12Place]+    public let placeSuppressions: [BackupV12PlaceSuppression]+    public let series: [BackupV12Series]+    public let links: [BackupV12Link]+    public let creators: [BackupV12Creator]+    public let creatorRoles: [BackupV12CreatorRole]+    public let credits: [BackupV12Credit]      public init(-        entries: [BackupV11Entry],-        works: [BackupV11Work],-        sites: [BackupV11Site],-        titlePatterns: [BackupV11TitlePattern],-        urlRules: [BackupV11URLRule],-        workTypes: [BackupV11WorkType] = [],-        memberships: [BackupV11Membership] = [],-        distinctPairs: [BackupV11DistinctPair] = [],-        characters: [BackupV11Character] = [],-        suppressions: [BackupV11Suppression] = [],-        series: [BackupV11Series] = [],-        links: [BackupV11Link] = [],-        creators: [BackupV11Creator] = [],-        creatorRoles: [BackupV11CreatorRole] = [],-        credits: [BackupV11Credit] = []+        entries: [BackupV12Entry],+        works: [BackupV12Work],+        sites: [BackupV12Site],+        titlePatterns: [BackupV12TitlePattern],+        urlRules: [BackupV12URLRule],+        workTypes: [BackupV12WorkType] = [],+        memberships: [BackupV12Membership] = [],+        distinctPairs: [BackupV12DistinctPair] = [],+        characters: [BackupV12Character] = [],+        suppressions: [BackupV12Suppression] = [],+        places: [BackupV12Place] = [],+        placeSuppressions: [BackupV12PlaceSuppression] = [],+        series: [BackupV12Series] = [],+        links: [BackupV12Link] = [],+        creators: [BackupV12Creator] = [],+        creatorRoles: [BackupV12CreatorRole] = [],+        credits: [BackupV12Credit] = []     ) {         self.entries = entries         self.works = works@@ -66,6 +70,8 @@ public struct BackupImportPayload: Sendable, Equatable {         self.memberships = memberships         self.characters = characters         self.suppressions = suppressions+        self.places = places+        self.placeSuppressions = placeSuppressions         self.series = series         self.creators = creators         self.creatorRoles = creatorRoles@@ -77,13 +83,14 @@ public struct BackupImportPayload: Sendable, Equatable {         self.credits = credits.map(\.normalized)     } -    public init(_ payload: BackupV11Payload) {+    public init(_ payload: BackupV12Payload) {         self.init(             entries: payload.entries, works: payload.works, sites: payload.sites,             titlePatterns: payload.titlePatterns, urlRules: payload.urlRules,             workTypes: payload.workTypes, memberships: payload.memberships,             distinctPairs: payload.distinctPairs, characters: payload.characters,-            suppressions: payload.suppressions, series: payload.series,+            suppressions: payload.suppressions, places: payload.places,+            placeSuppressions: payload.placeSuppressions, series: payload.series,             links: payload.links, creators: payload.creators,             creatorRoles: payload.creatorRoles, credits: payload.credits)     }@@ -106,7 +113,7 @@ public struct BackupImportPayload: Sendable, Equatable { /// process lease. Represents a complete validated prospective graph ready to be /// materialized atomically. ///-/// One source version is accepted, `11/12`. Every earlier generation's read path+/// One source version is accepted, `12/13`. Every earlier generation's read path /// has been retired in turn, `9/10` included (`series-and-related-works` Q13): a /// Work carries a series membership now, and the library carries a series table /// and a link table that a 9/10 record has no room for.@@ -127,7 +134,7 @@ public struct BackupImportPlan: Sendable, Equatable {      /// A plan over a wire payload, which is how every archive reaches one.     public init(-        metadata: BackupImportMetadata, payload: BackupV11Payload,+        metadata: BackupImportMetadata, payload: BackupV12Payload,         counts: LibraryRecordCounts     ) {         self.init(@@ -207,18 +214,18 @@ public enum BackupImportError: Error, Equatable, Sendable, CustomStringConvertib /// repository actor and without a process lease. Never mutates the selected /// file. ///-/// Import supports exact native `11/12` and nothing else. Mixed pairs, older+/// Import supports exact native `12/13` and nothing else. Mixed pairs, older /// generations and future headers reject before repository mutation — which is-/// the same door a *pre-feature* build meets `(10, 11)` at, and why a 11/12+/// the same door a *pre-feature* build meets `(10, 11)` at, and why a 12/13 /// archive cannot half-apply on one (Req 13.1). public enum BackupImporter {     private static let logger = Logger(subsystem: "me.nore.ig.Asterism", category: "BackupImporter") -    /// The pair this app reads and writes: format 11 over schema 12. It follows+    /// The pair this app reads and writes: format 12 over schema 13. It follows     /// the document's own constants, so the one accepted pair moves with the     /// generation rather than being restated here.     private static let supportedVersions = (-        format: BackupV11Document.formatVersion, schema: BackupV11Document.schemaVersion+        format: BackupV12Document.formatVersion, schema: BackupV12Document.schemaVersion     )      // MARK: - Plan Dispatch (Req 5.1, 5.2, Decision 2)@@ -249,9 +256,9 @@ public enum BackupImporter {     }      private static func planFromArchive(_ data: Data) throws -> BackupImportPlan {-        let document: BackupV11Document+        let document: BackupV12Document         do {-            document = try BackupV11Codec.decode(data)+            document = try BackupV12Codec.decode(data)         } catch {             throw BackupImportError.decodingFailed(reason: String(describing: error))         }
Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swiftindex 15f3c47..c5a5414 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupJSONCodecSupport.swift@@ -1,7 +1,7 @@ import Foundation  // Extracted from the retired `LegacyBackupV2Codec` when the 2/2 and 3/3 import-// paths were removed. Both helpers are used by the live `BackupV11Codec`: the+// paths were removed. Both helpers are used by the live `BackupV12Codec`: the // date formatter fixes the archive's timestamp encoding, and the duplicate-key // validator is what makes a decode strict rather than last-key-wins. //@@ -165,7 +165,7 @@ internal enum BackupArchiveDateFormatter { /// the live decode path: `BackupArchiveShapeValidator` goes through /// `JSONSerialization`, which collapses duplicates without complaint, so this is /// the only thing standing between a two-`payload` archive and importing the-/// wrong one. It also enforces no-trailing-bytes. `BackupV11ArchiveTests` covers+/// wrong one. It also enforces no-trailing-bytes. `BackupV12ArchiveTests` covers /// both properties by editing encoded bytes directly — they cannot be reached /// through any `JSONSerialization` round-trip. internal struct DuplicateJSONKeyValidator {
Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swift Renamed +54 / -28
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Codec.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swiftsimilarity index 75%rename from Packages/AsterismCore/Sources/AsterismCore/BackupV11Codec.swiftrename to Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swiftindex 307d61a..9c496e7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Codec.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Codec.swift@@ -1,42 +1,43 @@ import Foundation -/// The strict 11/12 archive codec: canonical JSON, a SHA-256 checksum over the+/// The strict 12/13 archive codec: canonical JSON, a SHA-256 checksum over the /// payload bytes, entry/work counts, root-strict envelope validation, typed /// nested decode, and a reference validator over the shared record checks plus /// this generation's own rules. ///-/// The only codec. It was cloned from the 10/11 one rather than grown out of it —-/// a shipped archive format is never redefined in place — and 10/11 was deleted-/// with the payload it described (`work-creators` Req 9.1). What it shares with nothing in+/// The only codec. It was cloned from the 11/12 one rather than grown out of it —+/// a shipped archive format is never redefined in place — and 11/12 was deleted+/// with the payload it described (`place-extraction` Req 5.1, Q51). What it+/// shares with nothing in /// particular, because there is nothing else, is stated once beside it: the /// record-level checking body (`BackupArchiveReferenceChecks`), the envelope /// shape (`BackupArchiveShapeValidator`) and the canonical JSON settings. /// /// The capability gate is pinned to the literal `"multi-site"`: the payload is /// frozen the moment it ships, and a later `AsterismCapabilities.current` must-/// not change what a 11/12 backup declares. 11/12 changes neither the store shape+/// not change what a 12/13 backup declares. 12/13 changes neither the store shape /// the gate names nor the rule-form set, so it keeps stamping it /// (`rule-citation-by-uuid` Q19).-public enum BackupV11Codec {-    /// Pinned literally. 11/12 ships at the multi-site gate; a future gate flip+public enum BackupV12Codec {+    /// Pinned literally. 12/13 ships at the multi-site gate; a future gate flip     /// cannot retroactively change what these files say.     static let gate = "multi-site"      /// The generation this codec speaks, as it appears in decode diagnostics.-    static let label = "V11"+    static let label = "V12"      // MARK: - Encode      public static func encode(-        payload: BackupV11Payload,-        metadata: BackupV11Metadata+        payload: BackupV12Payload,+        metadata: BackupV12Metadata     ) throws -> Data {         let encoder = BackupCanonicalJSON.encoder()          let payloadData = try encoder.encode(payload)         let checksum = BackupCanonicalJSON.sha256Hex(payloadData) -        let document = BackupV11Document(+        let document = BackupV12Document(             appBuild: metadata.appBuild,             exportedAt: metadata.exportedAt,             capabilityGate: Self.gate,@@ -51,7 +52,7 @@ public enum BackupV11Codec {      // MARK: - Decode -    /// Decodes and validates a 11/12 document. Validates: envelope format/schema,+    /// Decodes and validates a 12/13 document. Validates: envelope format/schema,     /// capability gate, duplicate keys, strict root shape, entry/work counts,     /// payload checksum, and all references and tuples.     ///@@ -61,21 +62,21 @@ public enum BackupV11Codec {     ///     /// The checksum step is also a refusal in its own right: it is taken over     /// the bytes as they arrived and compared against a re-encoding of what-    /// decoded, so a 11/12 file carrying a key this build does not write back —+    /// decoded, so a 12/13 file carrying a key this build does not write back —     /// a citation `version`, the shape a build before T-2281 wrote — fails here     /// rather than importing with the key silently dropped.-    public static func decode(_ data: Data) throws -> BackupV11Document {+    public static func decode(_ data: Data) throws -> BackupV12Document {         do {             try DuplicateJSONKeyValidator.validate(data)             try BackupArchiveShapeValidator.validate(data)              let document = try BackupCanonicalJSON.decoder()-                .decode(BackupV11Document.self, from: data)+                .decode(BackupV12Document.self, from: data) -            guard document.backupFormatVersion == BackupV11Document.formatVersion else {+            guard document.backupFormatVersion == BackupV12Document.formatVersion else {                 throw BackupCodecError.invalidFormatVersion(document.backupFormatVersion)             }-            guard document.databaseSchemaVersion == BackupV11Document.schemaVersion else {+            guard document.databaseSchemaVersion == BackupV12Document.schemaVersion else {                 throw BackupCodecError.invalidSchemaVersion(document.databaseSchemaVersion)             }             guard document.capabilityGate == Self.gate else {@@ -107,7 +108,7 @@ public enum BackupV11Codec {                 )             } -            try BackupV11ReferenceValidator.validate(payload: document.payload)+            try BackupV12ReferenceValidator.validate(payload: document.payload)              return document         } catch let error as BackupCodecError { throw error }@@ -117,9 +118,9 @@ public enum BackupV11Codec {     } } -// MARK: - V11 Metadata+// MARK: - V12 Metadata -public struct BackupV11Metadata: Sendable {+public struct BackupV12Metadata: Sendable {     public let appBuild: String     public let exportedAt: Date @@ -158,9 +159,10 @@ internal enum BackupArchiveShapeValidator {     } } -// MARK: - V11 Reference Validator+// MARK: - V12 Reference Validator -/// The shared record checks, the type-list rules, and the two character arrays.+/// The shared record checks, the type-list rules, the two character arrays and+/// the two place arrays. /// /// **What it deliberately does not check.** A fact's `sourceEntryID` and a fact /// suppression's are exempt (`character-extraction` Decision 2): a citation@@ -172,8 +174,8 @@ internal enum BackupArchiveShapeValidator { /// character or suppression, and a character or suppression naming a Work the /// file does not hold. The work reference is **optional, checked when present** /// — the `validateEntry` `workID` pattern — so an orphan passes.-internal enum BackupV11ReferenceValidator {-    static func validate(payload: BackupV11Payload) throws {+internal enum BackupV12ReferenceValidator {+    static func validate(payload: BackupV12Payload) throws {         do {             try BackupArchiveReferenceChecks.validate(                 entries: payload.entries,@@ -188,7 +190,7 @@ internal enum BackupV11ReferenceValidator {                 creators: payload.creators,                 creatorRoles: payload.creatorRoles,                 credits: payload.credits,-                formatLabel: BackupV11Codec.label)+                formatLabel: BackupV12Codec.label)         } catch let issue as BackupArchiveReferenceIssue {             throw BackupCodecError(issue)         }@@ -196,7 +198,7 @@ internal enum BackupV11ReferenceValidator {         let typeIDs = Set(payload.workTypes.map(\.id))         guard typeIDs.count == payload.workTypes.count else {             throw BackupCodecError.invalidStateTuple(-                type: "Payload", id: BackupV11Codec.label, reason: "duplicate work type ID")+                type: "Payload", id: BackupV12Codec.label, reason: "duplicate work type ID")         }          let workIDs = Set(payload.works.map(\.id))@@ -205,7 +207,7 @@ internal enum BackupV11ReferenceValidator {         for character in payload.characters {             guard characterIDs.insert(character.id).inserted else {                 throw BackupCodecError.invalidStateTuple(-                    type: "Payload", id: BackupV11Codec.label, reason: "duplicate Character ID")+                    type: "Payload", id: BackupV12Codec.label, reason: "duplicate Character ID")             }             if let workID = character.workID, !workIDs.contains(workID) {                 throw BackupCodecError.unresolvedReference(@@ -217,7 +219,7 @@ internal enum BackupV11ReferenceValidator {         for suppression in payload.suppressions {             guard suppressionIDs.insert(suppression.id).inserted else {                 throw BackupCodecError.invalidStateTuple(-                    type: "Payload", id: BackupV11Codec.label, reason: "duplicate CharacterSuppression ID")+                    type: "Payload", id: BackupV12Codec.label, reason: "duplicate CharacterSuppression ID")             }             if let workID = suppression.workID, !workIDs.contains(workID) {                 throw BackupCodecError.unresolvedReference(@@ -225,5 +227,29 @@ internal enum BackupV11ReferenceValidator {                     reference: "Work \(workID)")             }         }++        // The places, on the **contradiction** half of the rule only+        // (`place-extraction` Req 5.1, 5.5). Two records for one identity is a+        // payload disagreeing with itself, exactly as it is for a character. An+        // unresolvable `workID` is not: a place names its owner in a column, so+        // an owner that has not arrived is the tolerated orphan rather than a+        // dangling relationship, and refusing here would decline a backup over a+        // state every screen in the app already shows (Q60).+        var placeIDs: Set<UUID> = []+        for place in payload.places {+            guard placeIDs.insert(place.id).inserted else {+                throw BackupCodecError.invalidStateTuple(+                    type: "Payload", id: BackupV12Codec.label, reason: "duplicate Place ID")+            }+        }++        var placeSuppressionIDs: Set<UUID> = []+        for suppression in payload.placeSuppressions {+            guard placeSuppressionIDs.insert(suppression.id).inserted else {+                throw BackupCodecError.invalidStateTuple(+                    type: "Payload", id: BackupV12Codec.label,+                    reason: "duplicate PlaceSuppression ID")+            }+        }     } }
Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swift Renamed +98 / -52
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Exporter.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swiftsimilarity index 77%rename from Packages/AsterismCore/Sources/AsterismCore/BackupV11Exporter.swiftrename to Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swiftindex f6c2e35..df36e9a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Exporter.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Exporter.swift@@ -3,10 +3,10 @@ import SwiftData  // MARK: - Snapshot Providing -/// Provides one coherent 11/12 payload under a shared lock. Isolated from+/// Provides one coherent 12/13 payload under a shared lock. Isolated from /// persistence so export can be unit-tested with injected snapshots.-public protocol BackupV11SnapshotProviding: Sendable {-    func backupV11Snapshot() async throws -> BackupV11Payload+public protocol BackupV12SnapshotProviding: Sendable {+    func backupV12Snapshot() async throws -> BackupV12Payload }  // MARK: - Export Errors@@ -24,7 +24,7 @@ public protocol BackupV11SnapshotProviding: Sendable { /// the reader wrote exactly as it covers a torn Entry or Work. What does *not* /// refuse is a fact whose citation dangles — `character-extraction` Decision 2 /// makes that a tolerated state.-public enum BackupV11ExportError: Error, Equatable, Sendable, CustomStringConvertible {+public enum BackupV12ExportError: Error, Equatable, Sendable, CustomStringConvertible {     /// The store holds a **torn** identity group: one application UUID over rows     /// that disagree about something the reader wrote. The archive keys records     /// by UUID and cannot hold both variants, and silently dropping one is data@@ -69,8 +69,8 @@ public enum BackupV11ExportError: Error, Equatable, Sendable, CustomStringConver  // MARK: - LibraryRepository Snapshot -extension LibraryRepository: BackupV11SnapshotProviding {-    /// Provides a coherent 11/12 backup payload under a shared lock.+extension LibraryRepository: BackupV12SnapshotProviding {+    /// Provides a coherent 12/13 backup payload under a shared lock.     ///     /// **The quarantine and unresolved gates are gone** (Req 3.1 of     /// `cloudkit-mirroring`). They refused a file at exactly the moment one is@@ -88,16 +88,16 @@ extension LibraryRepository: BackupV11SnapshotProviding {     /// backup cannot mutate the library on the way out (Q38); the archive it     /// produces is nonetheless the shape reconciliation settles on, which is what     /// makes Req 3.5's round-trip hold.-    public func backupV11Snapshot() async throws -> BackupV11Payload {-        let outcome: Result<BackupV11Payload, BackupV11ExportError> =+    public func backupV12Snapshot() async throws -> BackupV12Payload {+        let outcome: Result<BackupV12Payload, BackupV12ExportError> =             try await withLockedBackupContext { context in-                do { return .success(try Self.projectV11Payload(context: context)) }-                catch let error as BackupV11ExportError { return .failure(error) }+                do { return .success(try Self.projectV12Payload(context: context)) }+                catch let error as BackupV12ExportError { return .failure(error) }             }         return try outcome.get()     } -    /// The whole 11/12 snapshot, from a context. Static and pure so the projection+    /// The whole 12/13 snapshot, from a context. Static and pure so the projection     /// can be exercised without an actor.     ///     /// **One projection pass.** `projectCommonArchiveRecords` already enumerated@@ -106,7 +106,7 @@ extension LibraryRepository: BackupV11SnapshotProviding {     /// vanishing), already refused a torn group, and already sorted by UUID.     /// Re-deriving any of it would be a second walk of the two largest tables and     /// a second chance to describe two moments.-    internal static func projectV11Payload(context: ModelContext) throws -> BackupV11Payload {+    internal static func projectV12Payload(context: ModelContext) throws -> BackupV12Payload {         let common = try projectCommonArchiveRecords(context: context)          // Req 7.2 and Q32: the **folded** list, one record per identity. Rows@@ -116,14 +116,14 @@ extension LibraryRepository: BackupV11SnapshotProviding {         // devices holding the same rows write the same bytes.         let directory = common.groups.types         let workTypes = directory.identities.map {-            BackupV11WorkType(+            BackupV12WorkType(                 id: $0.id, name: $0.name, stateRaw: $0.state.rawValue,                 canonicalID: $0.canonicalID, createdAt: $0.createdAt,                 modifiedAt: $0.modifiedAt)         }          let works = try common.groups.works.map {-            try mapV11WorkRecord(+            try mapV12WorkRecord(                 $0, canonicalWorkIDs: common.groups.canonicalWorkIDs, types: directory)         } @@ -137,10 +137,20 @@ extension LibraryRepository: BackupV11SnapshotProviding {             titlePatterns: common.titlePatterns,             urlRules: common.urlRules) -        let characters = common.groups.characters.map(mapV11CharacterRecord)+        let characters = common.groups.characters.map(mapV12CharacterRecord)          let suppressions = try context.fetch(FetchDescriptor<CharacterSuppression>())-            .map(mapV11SuppressionRecord)+            .map(mapV12SuppressionRecord)+            .sorted { $0.id.uuidString < $1.id.uuidString }++        // `place-extraction` Req 5.1. The place groups the common projection+        // already formed — enumerated whole and already refused if torn — and+        // the suppression table read whole beside them, on the character arms'+        // terms exactly.+        let places = common.groups.places.map(mapV12PlaceRecord)++        let placeSuppressions = try context.fetch(FetchDescriptor<PlaceSuppression>())+            .map(mapV12PlaceSuppressionRecord)             .sorted { $0.id.uuidString < $1.id.uuidString }          // `work-creators` Req 9.1: the two directories folded, one record per@@ -156,7 +166,7 @@ extension LibraryRepository: BackupV11SnapshotProviding {         let creators = electedCreators(try creatorDirectory(context: context))         let creatorRoles = electedCreatorRoles(try creatorRoleDirectory(context: context)) -        return BackupV11Payload(+        return BackupV12Payload(             entries: common.entries,             works: works,             sites: common.sites,@@ -167,13 +177,15 @@ extension LibraryRepository: BackupV11SnapshotProviding {             distinctPairs: try projectDistinctPairs(context: context),             characters: characters,             suppressions: suppressions,+            places: places,+            placeSuppressions: placeSuppressions,             // `series-and-related-works` Req 13.1 and 13.2: the series table             // whole, and one link per pair — the row the next reconcile would             // keep, so an archive never carries a row that pass deletes.             series: try projectSeries(context: context),             links: try projectLinks(context: context),-            creators: mapV11CreatorRecords(creators),-            creatorRoles: mapV11CreatorRoleRecords(creatorRoles),+            creators: mapV12CreatorRecords(creators),+            creatorRoles: mapV12CreatorRoleRecords(creatorRoles),             // Req 9.2: one record per work-and-creator pair, the row             // `dedupeCredits` would keep carrying the union it would write —             // so an archive never carries a state the next pass changes.@@ -254,11 +266,11 @@ extension LibraryRepository: BackupV11SnapshotProviding {     /// (Q68). Both shapes read identically on every device (unresolved, and     /// healing when the target arrives), and only one of them is a file the     /// reference checks accept.-    private static func mapV11CreatorRecords(+    private static func mapV12CreatorRecords(         _ directory: CreatorDirectory-    ) -> [BackupV11Creator] {+    ) -> [BackupV12Creator] {         directory.identities.map { identity in-            BackupV11Creator(+            BackupV12Creator(                 id: identity.id, name: identity.name,                 nameModifiedAt: identity.nameModifiedAt,                 notes: identity.notes, notesModifiedAt: identity.notesModifiedAt,@@ -270,11 +282,11 @@ extension LibraryRepository: BackupV11SnapshotProviding {         }     } -    private static func mapV11CreatorRoleRecords(+    private static func mapV12CreatorRoleRecords(         _ directory: CreatorRoleDirectory-    ) -> [BackupV11CreatorRole] {+    ) -> [BackupV12CreatorRole] {         directory.identities.map { identity in-            BackupV11CreatorRole(+            BackupV12CreatorRole(                 id: identity.id, name: identity.name,                 nameModifiedAt: identity.nameModifiedAt,                 position: identity.position,@@ -310,11 +322,11 @@ extension LibraryRepository: BackupV11SnapshotProviding {     /// 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 mapV11CharacterRecord(_ group: CharacterGroup) -> BackupV11Character {+    private static func mapV12CharacterRecord(_ group: CharacterGroup) -> BackupV12Character {         let content = group.presentedContent-        return BackupV11Character(+        return BackupV12Character(             id: group.id,-            workID: group.carrier.work?.id,+            workID: group.carrier.ownerWorkID,             name: content.name,             nameKey: group.carrier.nameKey,             aliases: content.aliases,@@ -327,11 +339,45 @@ extension LibraryRepository: BackupV11SnapshotProviding {     /// 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 mapV11SuppressionRecord(+    private static func mapV12SuppressionRecord(         _ row: CharacterSuppression-    ) -> BackupV11Suppression {-        BackupV11Suppression(-            id: row.id, workID: row.work?.id, kindRaw: row.kindRaw, nameKey: row.nameKey,+    ) -> BackupV12Suppression {+        BackupV12Suppression(+            id: row.id, workID: row.ownerWorkID, kindRaw: row.kindRaw, nameKey: row.nameKey,+            sourceKindRaw: row.sourceKindRaw, sourceEntryID: row.sourceEntryID,+            evidence: row.evidence, statusRaw: row.statusRaw, actionAt: row.actionAt)+    }++    /// `mapV12CharacterRecord` over a place group. The owner comes from the+    /// carrier's **column**, resolved or not: a place whose work has not arrived+    /// exports naming it, which is the only thing that lets Req 5.5's orphan+    /// round-trip (Q60).+    ///+    /// It names `workID` rather than `RecordRow.ownerWorkID` because the two+    /// archive records genuinely differ here: `BackupV12Place.workID` is a+    /// `UUID` and `BackupV12Character.workID` a `UUID?`, which is the same+    /// asymmetry the two tables have. That is also why the pair does not+    /// collapse into one generic mapper.+    private static func mapV12PlaceRecord(_ group: RecordGroup<Place>) -> BackupV12Place {+        let content = group.presentedContent+        return BackupV12Place(+            id: group.id,+            workID: group.carrier.workID,+            name: content.name,+            nameKey: group.carrier.nameKey,+            aliases: content.aliases,+            note: content.note,+            facts: content.facts,+            createdAt: group.createdAt,+            modifiedAt: group.modifiedAt)+    }++    /// One-for-one, duplicates included, for `mapV12SuppressionRecord`'s reason.+    private static func mapV12PlaceSuppressionRecord(+        _ row: PlaceSuppression+    ) -> BackupV12PlaceSuppression {+        BackupV12PlaceSuppression(+            id: row.id, workID: row.workID, kindRaw: row.kindRaw, nameKey: row.nameKey,             sourceKindRaw: row.sourceKindRaw, sourceEntryID: row.sourceEntryID,             evidence: row.evidence, statusRaw: row.statusRaw, actionAt: row.actionAt)     }@@ -339,48 +385,48 @@ extension LibraryRepository: BackupV11SnapshotProviding {  // MARK: - The Exporter -/// Orchestrates coherent 11/12 snapshot → validated encoding → staging.+/// Orchestrates coherent 12/13 snapshot → validated encoding → staging. /// /// The only exporter. It decode-validates its own bytes before sharing, so a-/// produced file is always a valid strict 11/12 document.-public final class BackupV11Exporter: Sendable {-    private let repository: any BackupV11SnapshotProviding+/// produced file is always a valid strict 12/13 document.+public final class BackupV12Exporter: Sendable {+    private let repository: any BackupV12SnapshotProviding     private let stagingDirectory: URL      public init(-        repository: any BackupV11SnapshotProviding,+        repository: any BackupV12SnapshotProviding,         stagingDirectory: URL     ) {         self.repository = repository         self.stagingDirectory = stagingDirectory     } -    public func export(metadata: BackupV11Metadata) async throws -> BackupExportResult {-        let payload: BackupV11Payload+    public func export(metadata: BackupV12Metadata) async throws -> BackupExportResult {+        let payload: BackupV12Payload         do {-            payload = try await repository.backupV11Snapshot()-        } catch let error as BackupV11ExportError {+            payload = try await repository.backupV12Snapshot()+        } catch let error as BackupV12ExportError {             throw error         } catch {-            throw BackupV11ExportError.snapshotFailed(reason: String(describing: error))+            throw BackupV12ExportError.snapshotFailed(reason: String(describing: error))         }          let encoded: Data         do {-            encoded = try BackupV11Codec.encode(payload: payload, metadata: metadata)+            encoded = try BackupV12Codec.encode(payload: payload, metadata: metadata)         } catch {-            throw BackupV11ExportError.encodingFailed(reason: String(describing: error))+            throw BackupV12ExportError.encodingFailed(reason: String(describing: error))         }          do {-            let decoded = try BackupV11Codec.decode(encoded)+            let decoded = try BackupV12Codec.decode(encoded)             guard decoded.payload == payload else {-                throw BackupV11ExportError.encodingFailed(reason: "decode-validation payload mismatch")+                throw BackupV12ExportError.encodingFailed(reason: "decode-validation payload mismatch")             }-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             throw error         } catch {-            throw BackupV11ExportError.encodingFailed(reason: "decode-validation failed: \(error)")+            throw BackupV12ExportError.encodingFailed(reason: "decode-validation failed: \(error)")         }          do {@@ -388,17 +434,17 @@ public final class BackupV11Exporter: Sendable {                 at: stagingDirectory, withIntermediateDirectories: true)             let fileURL = stagingDirectory.appending(                 path: ExportStaging.backupFilename(-                    version: "v11", exportedAt: metadata.exportedAt))+                    version: "v12", exportedAt: metadata.exportedAt))             do {                 try ExportStaging.write(encoded, to: fileURL)             } catch {-                throw BackupV11ExportError.stagingFailed(reason: String(describing: error))+                throw BackupV12ExportError.stagingFailed(reason: String(describing: error))             }             return BackupExportResult(fileURL: fileURL)-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             throw error         } catch {-            throw BackupV11ExportError.stagingFailed(+            throw BackupV12ExportError.stagingFailed(                 reason: "preparing staging directory failed: \(error)")         }     }
Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swift Renamed +206 / -81
diff --git a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Types.swift b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swiftsimilarity index 76%rename from Packages/AsterismCore/Sources/AsterismCore/BackupV11Types.swiftrename to Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swiftindex e03deb5..fc58a77 100644--- a/Packages/AsterismCore/Sources/AsterismCore/BackupV11Types.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/BackupV12Types.swift@@ -1,23 +1,28 @@ import Foundation -// MARK: - Backup V11 Document+// MARK: - Backup V12 Document -/// The 11/12 backup envelope: format version 11 over schema version 12-/// (`work-creators` Req 9.1). The schema number names the store schema the-/// archive was taken from, which is V12.+/// The 12/13 backup envelope: format version 12 over schema version 13+/// (`place-extraction` Req 5.1). The schema number names the store schema the+/// archive was taken from, which is V13. ///-/// It **replaces** the 10/11 set outright rather than standing beside it-/// (`rule-citation-by-uuid` Q14, restated at every generation since): the-/// library carries a creator table, a role table and a credit table now, and a-/// 10/11 file holds none of them — every credit the reader entered would have to-/// be invented as absent. An archive written before 11/12 is refused by version,-/// with the message naming the pair it declares (Req 9.1).+/// It **replaces** the 11/12 set outright rather than standing beside it+/// (`rule-citation-by-uuid` Q14, restated at every generation since, and Q51+/// here): the library carries a place table and a place-suppression table now,+/// and an 11/12 file holds neither — every place the reader accepted would have+/// to be invented as absent. An archive written before 12/13 is refused by+/// version, with the message naming the pair it declares (Req 5.1).+///+/// The schema number is **pinned to the live schema by a test**+/// (`BackupV12CodecTests.envelopeVersionsFollowTheLiveSchema`, Q66): a schema+/// bump moves four things and this is the only one of them that fails silently+/// when it is forgotten. /// /// The envelope keys are 4/4's, unchanged through every generation since. /// Everything this one changes is inside `payload`.-public struct BackupV11Document: Codable, Equatable, Sendable {-    public static let formatVersion = 11-    public static let schemaVersion = 12+public struct BackupV12Document: Codable, Equatable, Sendable {+    public static let formatVersion = 12+    public static let schemaVersion = 13      public let backupFormatVersion: Int     public let databaseSchemaVersion: Int@@ -27,7 +32,7 @@ public struct BackupV11Document: Codable, Equatable, Sendable {     public let entryCount: Int     public let workCount: Int     public let checksum: String-    public let payload: BackupV11Payload+    public let payload: BackupV12Payload      public init(         appBuild: String,@@ -36,7 +41,7 @@ public struct BackupV11Document: Codable, Equatable, Sendable {         entryCount: Int,         workCount: Int,         checksum: String,-        payload: BackupV11Payload+        payload: BackupV12Payload     ) {         backupFormatVersion = Self.formatVersion         databaseSchemaVersion = Self.schemaVersion@@ -50,9 +55,9 @@ public struct BackupV11Document: Codable, Equatable, Sendable {     } } -// MARK: - V11 Payload+// MARK: - V12 Payload -/// The fifteen arrays an 11/12 archive holds.+/// The seventeen arrays a 12/13 archive holds. /// /// **Every record kind is enumerated whole**, and no parent record carries a /// list of its children (Req 9.3). The 6/7 payload had a Site naming its rules,@@ -62,56 +67,67 @@ public struct BackupV11Document: Codable, Equatable, Sendable { /// distinct pairs arrive under the same rule the characters do (Q17): a row /// whose Work has not arrived exports naming the Work it belongs to, and imports /// unattached (Req 9.5, Q22).-public struct BackupV11Payload: Codable, Equatable, Sendable {-    public let entries: [BackupV11Entry]-    public let works: [BackupV11Work]-    public let sites: [BackupV11Site]-    public let titlePatterns: [BackupV11TitlePattern]-    public let urlRules: [BackupV11URLRule]-    public let workTypes: [BackupV11WorkType]+public struct BackupV12Payload: Codable, Equatable, Sendable {+    public let entries: [BackupV12Entry]+    public let works: [BackupV12Work]+    public let sites: [BackupV12Site]+    public let titlePatterns: [BackupV12TitlePattern]+    public let urlRules: [BackupV12URLRule]+    public let workTypes: [BackupV12WorkType]     /// One row per Work and hostname (Req 9.1). The Work's site presence lives     /// here and nowhere else.-    public let memberships: [BackupV11Membership]+    public let memberships: [BackupV12Membership]     /// The reader's "not the same work" over an unordered pair (Req 5.5).-    public let distinctPairs: [BackupV11DistinctPair]-    public let characters: [BackupV11Character]-    public let suppressions: [BackupV11Suppression]+    public let distinctPairs: [BackupV12DistinctPair]+    public let characters: [BackupV12Character]+    public let suppressions: [BackupV12Suppression]+    /// The reader's places (`place-extraction` Req 5.1), enumerated whole for+    /// the reason the characters are — and one more: a `Place` declares no+    /// relationship at all, so an orphan is reachable only this way (Req 5.5).+    public let places: [BackupV12Place]+    /// The place half of the suppression ledger. A separate array rather than a+    /// kind column on `suppressions`, because the store keeps the two in+    /// separate tables (Decision 2) and a shared array would be a second+    /// spelling of that split.+    public let placeSuppressions: [BackupV12PlaceSuppression]     /// The reader's series (`series-and-related-works` Req 13.1). A work's     /// **membership** is not here: it is part of the work's own record, and     /// travels on it.-    public let series: [BackupV11Series]+    public let series: [BackupV12Series]     /// One row per linked pair, the survivor the next reconcile would keep     /// (Req 13.2, Q14). A link naming a work the archive does not carry is     /// legal and imports unresolved, exactly as a distinct pair does.-    public let links: [BackupV11Link]+    public let links: [BackupV12Link]     /// The reader's creators, one record per folded identity     /// (`work-creators` Req 9.1).-    public let creators: [BackupV11Creator]+    public let creators: [BackupV12Creator]     /// The reader's role list, one record per folded identity, carrying the     /// list order every credit is read in.-    public let creatorRoles: [BackupV11CreatorRole]+    public let creatorRoles: [BackupV12CreatorRole]     /// One record per work-and-creator pair, as     /// [10.5](../../../../specs/work-creators/requirements.md#10.5) would keep     /// it (Req 9.2). A credit naming a work, creator or role the archive does     /// not carry is legal and imports unresolved (Req 9.5).-    public let credits: [BackupV11Credit]+    public let credits: [BackupV12Credit]      public init(-        entries: [BackupV11Entry],-        works: [BackupV11Work],-        sites: [BackupV11Site],-        titlePatterns: [BackupV11TitlePattern],-        urlRules: [BackupV11URLRule],-        workTypes: [BackupV11WorkType] = [],-        memberships: [BackupV11Membership] = [],-        distinctPairs: [BackupV11DistinctPair] = [],-        characters: [BackupV11Character] = [],-        suppressions: [BackupV11Suppression] = [],-        series: [BackupV11Series] = [],-        links: [BackupV11Link] = [],-        creators: [BackupV11Creator] = [],-        creatorRoles: [BackupV11CreatorRole] = [],-        credits: [BackupV11Credit] = []+        entries: [BackupV12Entry],+        works: [BackupV12Work],+        sites: [BackupV12Site],+        titlePatterns: [BackupV12TitlePattern],+        urlRules: [BackupV12URLRule],+        workTypes: [BackupV12WorkType] = [],+        memberships: [BackupV12Membership] = [],+        distinctPairs: [BackupV12DistinctPair] = [],+        characters: [BackupV12Character] = [],+        suppressions: [BackupV12Suppression] = [],+        places: [BackupV12Place] = [],+        placeSuppressions: [BackupV12PlaceSuppression] = [],+        series: [BackupV12Series] = [],+        links: [BackupV12Link] = [],+        creators: [BackupV12Creator] = [],+        creatorRoles: [BackupV12CreatorRole] = [],+        credits: [BackupV12Credit] = []     ) {         self.entries = entries         self.works = works@@ -123,6 +139,8 @@ public struct BackupV11Payload: Codable, Equatable, Sendable {         self.distinctPairs = distinctPairs         self.characters = characters         self.suppressions = suppressions+        self.places = places+        self.placeSuppressions = placeSuppressions         self.series = series         self.links = links         self.creators = creators@@ -131,12 +149,12 @@ public struct BackupV11Payload: Codable, Equatable, Sendable {     } } -// MARK: - V11 Records+// MARK: - V12 Records  /// One Site. **No child lists** (Req 9.3): a title rule and a URL rule each name /// their hostname, so the Site naming them back was a second spelling of one /// relationship — and the one the reference checks had to cross-examine.-public struct BackupV11Site: Codable, Equatable, Sendable {+public struct BackupV12Site: Codable, Equatable, Sendable {     public let hostname: String     public let displayName: String     public let mode: SiteMode@@ -158,7 +176,7 @@ public struct BackupV11Site: Codable, Equatable, Sendable { /// One title rule. The arm and both trims travel as one `StoredPatternDefinition` /// — the value V8 stores in `TitlePattern.definitionData` (Q25) — rather than as /// a definition beside two loose trim columns.-public struct BackupV11TitlePattern: Codable, Equatable, Sendable {+public struct BackupV12TitlePattern: Codable, Equatable, Sendable {     public let id: UUID     public let siteHostname: String     public let version: Int@@ -185,7 +203,7 @@ public struct BackupV11TitlePattern: Codable, Equatable, Sendable {  /// One URL rule, unchanged from the generation that froze it: it never carried a /// child list and its definition was already one value.-public struct BackupV11URLRule: Codable, Equatable, Sendable {+public struct BackupV12URLRule: Codable, Equatable, Sendable {     public let id: UUID     public let version: Int     public let isCurrent: Bool@@ -219,7 +237,7 @@ public struct BackupV11URLRule: Codable, Equatable, Sendable { /// one UUID are a normal permanent state in the live store, so the exporter /// writes the directory's folded identity rather than the rows, and `modifiedAt` /// on the wire is the fold's max.-public struct BackupV11WorkType: Codable, Equatable, Sendable {+public struct BackupV12WorkType: Codable, Equatable, Sendable {     public let id: UUID     public let name: String     /// `active` / `removed` / `merged`, carried raw so an archive written by a@@ -265,7 +283,7 @@ public struct BackupV11WorkType: Codable, Equatable, Sendable { /// build accepts is one it wrote, so a record missing them is malformed, and a /// default on decode would restore a reader's abandoned work as one they are /// still reading rather than saying so.-public struct BackupV11Work: Codable, Equatable, Sendable {+public struct BackupV12Work: Codable, Equatable, Sendable {     public let id: UUID     public let displayTitle: String     public let lastParsedTitle: String?@@ -279,7 +297,7 @@ public struct BackupV11Work: Codable, Equatable, Sendable {     /// The reader's own line about it, trimmed on write and carried verbatim     /// here — empty when they have not written one.     public let verdict: String-    /// Cites a `BackupV11WorkType`, or an entry this archive could not carry — a+    /// Cites a `BackupV12WorkType`, or an entry this archive could not carry — a     /// dangling id exports verbatim and imports as unresolved rather than     /// refusing or inventing a name (`configurable-work-types` Q24).     public let workTypeID: UUID?@@ -357,7 +375,7 @@ public struct BackupV11Work: Codable, Equatable, Sendable { /// payload (Req 9.3). Names need not be unique (Q11), so there is nothing here /// to elect and nothing to fold — two rows sharing an id would be duplicate rows /// of one series, and the reference checks refuse a payload holding both.-public struct BackupV11Series: Codable, Equatable, Sendable {+public struct BackupV12Series: Codable, Equatable, Sendable {     public let id: UUID     /// Stored trimmed and non-empty; an empty trimmed name is refused at the     /// door (Req 13.5).@@ -379,12 +397,12 @@ public struct BackupV11Series: Codable, Equatable, Sendable {  /// One related-work link (`series-and-related-works` Req 13.1). ///-/// `BackupV11DistinctPair`'s shape, because the store's row is: two UUIDs in the+/// `BackupV12DistinctPair`'s shape, because the store's row is: two UUIDs in the /// canonical sorted order, naming works the archive may not carry — an /// unresolved link imports verbatim and is tolerated (Req 13.5, 11.2). What it /// adds is the reader's type and a modification time, which is the survivor key /// over a duplicated pair (Req 11.4, Q27).-public struct BackupV11Link: Codable, Equatable, Sendable {+public struct BackupV12Link: Codable, Equatable, Sendable {     public let id: UUID     public let lowerWorkID: UUID     public let higherWorkID: UUID@@ -405,14 +423,14 @@ public struct BackupV11Link: Codable, Equatable, Sendable {     }      /// The record with its two ids in the canonical order, whatever order they-    /// arrived in — `BackupV11DistinctPair.sorted`'s reason exactly: an unsorted+    /// arrived in — `BackupV12DistinctPair.sorted`'s reason exactly: an unsorted     /// pair is a second spelling of one link, and `MembershipReconciler.dedupeLinks`     /// groups on the sorted form, so a hand-built archive's row is normalised on     /// the way in rather than left as a duplicate nothing would ever match.-    public var sorted: BackupV11Link {+    public var sorted: BackupV12Link {         let ids = WorkDistinctPair.sortedIDs(lowerWorkID, higherWorkID)         guard ids.lower != lowerWorkID || ids.higher != higherWorkID else { return self }-        return BackupV11Link(+        return BackupV12Link(             id: id, lowerWorkID: ids.lower, higherWorkID: ids.higher, linkType: linkType,             createdAt: createdAt, modifiedAt: modifiedAt)     }@@ -420,7 +438,7 @@ public struct BackupV11Link: Codable, Equatable, Sendable {  /// One creator (`work-creators` Req 9.1). ///-/// **One record per folded identity**, `BackupV11WorkType`'s rule — rows sharing+/// **One record per folded identity**, `BackupV12WorkType`'s rule — rows sharing /// a UUID are one record everywhere else in the app — but carrying the **per-field /// modification times** the directory fold reads (Q50). A single `modifiedAt` /// cannot drive a per-field fold, and stamping it onto every field would make@@ -429,7 +447,7 @@ public struct BackupV11Link: Codable, Equatable, Sendable { /// A `merged` record names its **final** survivor (Q33): the exporter chases the /// alias chain, so an archive never carries a pointer at a record that is itself /// merged, and the reference checks refuse one.-public struct BackupV11Creator: Codable, Equatable, Sendable {+public struct BackupV12Creator: Codable, Equatable, Sendable {     public let id: UUID     /// The stored spelling, trimmed. Normalized names are computed, never     /// stored, so the file carries what the reader typed.@@ -477,10 +495,10 @@ public struct BackupV11Creator: Codable, Equatable, Sendable {  /// One role of the reader's list (`work-creators` Req 9.1). ///-/// `BackupV11Creator`'s shape with `position` in the place of notes, and a third+/// `BackupV12Creator`'s shape with `position` in the place of notes, and a third /// state: a removed role is retained so that re-adding its name restores it and /// the credits holding it keep the identifier (Decision 4).-public struct BackupV11CreatorRole: Codable, Equatable, Sendable {+public struct BackupV12CreatorRole: Codable, Equatable, Sendable {     public let id: UUID     public let name: String     public let nameModifiedAt: Date@@ -522,7 +540,7 @@ public struct BackupV11CreatorRole: Codable, Equatable, Sendable { /// One credit: that one creator worked on one work, in the roles it holds /// (`work-creators` Req 9.1). ///-/// `BackupV11Link`'s shape, because the store's row is: plain identifier columns+/// `BackupV12Link`'s shape, because the store's row is: plain identifier columns /// naming records the archive may not carry. A credit whose work, creator or role /// is absent imports **unresolved** and is tolerated (Req 9.5, /// [10.2](../../../../specs/work-creators/requirements.md#10.2)) — it is never a@@ -533,7 +551,7 @@ public struct BackupV11CreatorRole: Codable, Equatable, Sendable { /// so a restore after a remove-then-restore would lose pairings /// [2.2](../../../../specs/work-creators/requirements.md#2.2) promises to bring /// back. It is sorted and deduplicated, as every write leaves it.-public struct BackupV11Credit: Codable, Equatable, Sendable {+public struct BackupV12Credit: Codable, Equatable, Sendable {     public let id: UUID     public let workID: UUID     public let creatorID: UUID@@ -562,15 +580,15 @@ public struct BackupV11Credit: Codable, Equatable, Sendable {     /// The record with its role identifiers in the one order a stored set has —     /// sorted and deduplicated, as every writer leaves them.     ///-    /// `BackupV11Link.sorted`'s role and its reason: an unsorted or repeated+    /// `BackupV12Link.sorted`'s role and its reason: an unsorted or repeated     /// list is a second spelling of one role set, and every comparison the     /// import makes is an equality on the array. The wire check refuses a     /// repeat *as stored* first (Req 9.5), so this normalises a hand-built file     /// on the way to the commit rather than hiding a refusal.-    public var normalized: BackupV11Credit {+    public var normalized: BackupV12Credit {         let roles = WorkCreditSupport.roleIDs(roleIDs)         guard roles != roleIDs else { return self }-        return BackupV11Credit(+        return BackupV12Credit(             id: id, workID: workID, creatorID: creatorID, roleIDs: roles,             createdAt: createdAt, modifiedAt: modifiedAt)     }@@ -586,7 +604,7 @@ public struct BackupV11Credit: Codable, Equatable, Sendable { /// /// The cited rule is a **bare UUID**: a membership names the rule row and /// carries no rule version (Req 10.4, Q28).-public struct BackupV11Membership: Codable, Equatable, Sendable {+public struct BackupV12Membership: Codable, Equatable, Sendable {     public let id: UUID     public let workID: UUID?     public let hostname: String@@ -623,7 +641,7 @@ public struct BackupV11Membership: Codable, Equatable, Sendable { /// unordered, so it has one spelling, and it names Works the archive may not /// carry — a pair whose Works have not arrived imports verbatim and is tolerated /// (Req 8.3).-public struct BackupV11DistinctPair: Codable, Equatable, Sendable {+public struct BackupV12DistinctPair: Codable, Equatable, Sendable {     public let id: UUID     public let lowerWorkID: UUID     public let higherWorkID: UUID@@ -641,10 +659,10 @@ public struct BackupV11DistinctPair: Codable, Equatable, Sendable {     /// `MembershipReconciler.dedupePairs` groups on the sorted form — so a     /// hand-built or older archive's row is normalised on the way in rather than     /// left as a duplicate nothing would ever match (task 20/21 review).-    public var sorted: BackupV11DistinctPair {+    public var sorted: BackupV12DistinctPair {         let ids = WorkDistinctPair.sortedIDs(lowerWorkID, higherWorkID)         guard ids.lower != lowerWorkID || ids.higher != higherWorkID else { return self }-        return BackupV11DistinctPair(+        return BackupV12DistinctPair(             id: id, lowerWorkID: ids.lower, higherWorkID: ids.higher, recordedAt: recordedAt)     } }@@ -664,7 +682,7 @@ public struct BackupV11DistinctPair: Codable, Equatable, Sendable { /// /// `characterExtractionFingerprint` rides on the record whose `note` it /// describes (Req 9.4), for the reason the Work's does.-public struct BackupV11Entry: Codable, Equatable, Sendable {+public struct BackupV12Entry: Codable, Equatable, Sendable {     public let id: UUID     public let captureTitle: String     public let captureTitleSource: CaptureTitleSource@@ -739,7 +757,7 @@ public struct BackupV11Entry: Codable, Equatable, Sendable {  /// One character, as the archive holds it. ///-/// `facts` carries `CharacterFact` itself rather than a wire clone of it: the+/// `facts` carries `RecordFact` itself rather than a wire clone of it: the /// fact *is* a value type with a stable Codable shape, and a second spelling /// would be two definitions of one thing with no way to notice them drifting. ///@@ -747,7 +765,7 @@ public struct BackupV11Entry: Codable, Equatable, Sendable { /// has not arrived is a tolerated in-flight state /// (`character-extraction` Q78), a reference to a work the archive does not /// carry is a file contradicting itself.-public struct BackupV11Character: Codable, Equatable, Sendable {+public struct BackupV12Character: Codable, Equatable, Sendable {     public let id: UUID     public let workID: UUID?     public let name: String@@ -756,7 +774,7 @@ public struct BackupV11Character: Codable, Equatable, Sendable {     public let nameKey: String     public let aliases: [String]     public let note: String-    public let facts: [CharacterFact]+    public let facts: [RecordFact]     public let createdAt: Date     public let modifiedAt: Date @@ -767,7 +785,7 @@ public struct BackupV11Character: Codable, Equatable, Sendable {         nameKey: String,         aliases: [String],         note: String,-        facts: [CharacterFact],+        facts: [RecordFact],         createdAt: Date,         modifiedAt: Date     ) {@@ -785,11 +803,11 @@ public struct BackupV11Character: Codable, Equatable, Sendable {  /// One suppression row. ///-/// The enum columns travel **raw**, for the reason `BackupV11WorkType`'s+/// The enum columns travel **raw**, for the reason `BackupV12WorkType`'s /// `stateRaw` does: an archive written by a later build's wider set decodes here /// rather than refusing, and the store's own coercion answers for a value this /// build cannot name.-public struct BackupV11Suppression: Codable, Equatable, Sendable {+public struct BackupV12Suppression: Codable, Equatable, Sendable {     public let id: UUID     public let workID: UUID?     public let kindRaw: String@@ -838,3 +856,110 @@ public struct BackupV11Suppression: Codable, Equatable, Sendable {         SourceRef(kindRaw: sourceKindRaw, entryID: sourceEntryID)     } }++/// One place, as the archive holds it (`place-extraction` Req 5.1).+///+/// `BackupV12Character`'s fields with **one difference**: `workID` is not+/// optional. A place names its owner in a UUID column rather than through a+/// relationship, so the two kinds of absence a character has — no relationship,+/// and a relationship pointing at a row that has not arrived — collapse into+/// one: a `workID` nothing resolves (Q60, Q67).+///+/// The consequence for the validator is the one asymmetry between the two+/// tables: a character naming a work the file does not carry is a file+/// contradicting itself and refuses, while a place naming one is the tolerated+/// orphan of Req 5.5 and is carried verbatim. There is no shape it could be+/// rewritten to that would say anything truer.+public struct BackupV12Place: Codable, Equatable, Sendable {+    public let id: UUID+    /// The owning work, as the row states it. Kept whether or not it resolves —+    /// the import writes it back unchanged, so an orphan round-trips.+    public let workID: UUID+    public let name: String+    /// The retained key, minted at accept or creation and never re-derived from+    /// a rename, so it travels rather than being recomputed on the way in.+    public let nameKey: String+    public let aliases: [String]+    public let note: String+    public let facts: [RecordFact]+    public let createdAt: Date+    public let modifiedAt: Date++    public init(+        id: UUID,+        workID: UUID,+        name: String,+        nameKey: String,+        aliases: [String],+        note: String,+        facts: [RecordFact],+        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 place-suppression row.+///+/// `BackupV12Suppression`'s shape with `BackupV12Place`'s ownership column, and+/// the same tolerance: the enum columns travel **raw** so a value a later+/// build's wider set wrote survives the round trip, and the owner is carried+/// whether or not it resolves.+public struct BackupV12PlaceSuppression: Codable, Equatable, Sendable {+    public let id: UUID+    public let workID: UUID+    public let kindRaw: String+    public let nameKey: String+    /// Present on fact rows only. Explicit rather than inferred from a nil+    /// `sourceEntryID`, so a malformed row is distinguishable from a+    /// generic-notes citation.+    public let sourceKindRaw: String?+    public let sourceEntryID: UUID?+    public let evidence: String?+    public let statusRaw: String+    /// When the reader acted — the comparable the import value-guards with.+    public let actionAt: Date++    public init(+        id: UUID,+        workID: UUID,+        kindRaw: String,+        nameKey: String,+        sourceKindRaw: String?,+        sourceEntryID: UUID?,+        evidence: String?,+        statusRaw: String,+        actionAt: Date+    ) {+        self.id = id+        self.workID = workID+        self.kindRaw = kindRaw+        self.nameKey = nameKey+        self.sourceKindRaw = sourceKindRaw+        self.sourceEntryID = sourceEntryID+        self.evidence = evidence+        self.statusRaw = statusRaw+        self.actionAt = actionAt+    }++    public var kind: CharacterSuppressionKind {+        ToleratedEnum.read(kindRaw, default: .candidate)+    }++    public var status: CharacterSuppressionStatus {+        ToleratedEnum.read(statusRaw, default: .active)+    }++    public var source: SourceRef? {+        SourceRef(kindRaw: sourceKindRaw, entryID: sourceEntryID)+    }+}
Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift b/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swiftindex 2a679f9..566456b 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CanonicalBytes.swift@@ -32,7 +32,7 @@ extension JSONEncoder.OutputFormatting {     ///     /// 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+    /// and by `RecordFactCodec` (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 =
Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift Modified +146 / -58
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift b/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swiftindex e952464..609f39c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift@@ -6,10 +6,23 @@ import Foundation // 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.+//+// **Every question about records is asked per `RecordKind`** (Q13). One request+// returns characters and places, one candidate read supplies both halves, and+// one commit writes either — but a place fact is matched against accepted place+// facts and place suppressions only, and a skipped place "Bay" never silences a+// character "Bay".  // MARK: - The candidate read (Q78)  /// One source of one work, as the sweep's filter sees it.+///+/// **The name keeps its `Character` prefix** where the design's rename table+/// says `ExtractionSource`: `AsterismIntelligence` already owns a public+/// `ExtractionSource` — the model *request* shape — and the app imports both+/// modules unqualified, so the two would be ambiguous at every use site. A+/// source is kind-independent anyway (a note is a note), so the pipeline's own+/// name for it is not a lie. public struct CharacterExtractionSource: Sendable, Equatable {     public let ref: SourceRef     /// The source's current text. The model request carries this and the work's@@ -28,21 +41,26 @@ public struct CharacterExtractionSource: Sendable, Equatable {     }      /// Coverage is per revision: editing the note *is* the invalidation.+    ///+    /// One column per source whatever kind decided it (Q9): a place decision+    /// covers the note exactly as a character decision does, and no second+    /// coverage column exists to disagree with this one.     public var isCovered: Bool { coveredFingerprint == fingerprint } } -/// An existing character, as decision-time matching sees it (Req 2.3, Q67).-public struct CharacterMatchTarget: Sendable, Equatable {+/// An existing record of one kind, as decision-time matching sees it+/// (Req 2.3, Q67).+public struct MatchTarget: Sendable, Equatable {     public let id: UUID-    /// The key derived from the character's **current** name — the first tier.+    /// The key derived from the record'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).+    /// Whether the record's identity group is torn. Acceptance onto a torn+    /// record is refused (Req 2.8, Q48).     public let isTorn: Bool      public init(@@ -56,27 +74,31 @@ public struct CharacterMatchTarget: Sendable, Equatable {     } } -/// The suppressions that stand for one work, read through Q82's convergence.-public struct CharacterSuppressionIndex: Sendable, Equatable {+/// The suppressions that stand for one work **and one kind**, read through Q82's+/// convergence.+public struct SuppressionIndex: Sendable, Equatable {     /// Name keys a skipped candidate suppressed. Blocks **new candidates only**,-    /// never a bundle for an existing character (Q47).+    /// never a bundle for an existing record (Q47).     public let candidateKeys: Set<String>     /// Fact identity triples an untick or a deletion suppressed.-    public let factIdentities: Set<CharacterFactIdentity>+    public let factIdentities: Set<RecordFactIdentity> -    public init(candidateKeys: Set<String>, factIdentities: Set<CharacterFactIdentity>) {+    public init(candidateKeys: Set<String>, factIdentities: Set<RecordFactIdentity>) {         self.candidateKeys = candidateKeys         self.factIdentities = factIdentities     } -    public static let empty = CharacterSuppressionIndex(-        candidateKeys: [], factIdentities: [])+    public static let empty = SuppressionIndex(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 {+///+/// Three of its four halves are keyed by `RecordKind`. The sources are not: a+/// source is a note, and coverage is one record per source revision whichever+/// kind decided it (Q9).+public struct ExtractionCandidate: Sendable, Equatable {     public let workID: UUID     public let displayTitle: String     /// The ordering key: the newest of the noted entries'@@ -85,59 +107,71 @@ public struct CharacterExtractionCandidate: Sendable, Equatable {     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+    /// The work's records of each kind, in the terms matching cares about.+    public let records: [RecordKind: [MatchTarget]]+    /// Every accepted fact's identity triple, per kind, so no pass re-proposes+    /// decided content (Req 1.7).+    public let acceptedFacts: [RecordKind: Set<RecordFactIdentity>]+    public let suppressions: [RecordKind: SuppressionIndex]      public init(         workID: UUID,         displayTitle: String,         recency: Date,         sources: [CharacterExtractionSource],-        characters: [CharacterMatchTarget],-        acceptedFactIdentities: Set<CharacterFactIdentity>,-        suppressions: CharacterSuppressionIndex+        records: [RecordKind: [MatchTarget]],+        acceptedFacts: [RecordKind: Set<RecordFactIdentity>],+        suppressions: [RecordKind: SuppressionIndex]     ) {         self.workID = workID         self.displayTitle = displayTitle         self.recency = recency         self.sources = sources-        self.characters = characters-        self.acceptedFactIdentities = acceptedFactIdentities+        self.records = records+        self.acceptedFacts = acceptedFacts         self.suppressions = suppressions     } +    public func records(of kind: RecordKind) -> [MatchTarget] { records[kind] ?? [] }++    public func acceptedFacts(of kind: RecordKind) -> Set<RecordFactIdentity> {+        acceptedFacts[kind] ?? []+    }++    public func suppressions(of kind: RecordKind) -> SuppressionIndex {+        suppressions[kind] ?? .empty+    }+     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 tiers over this work's records **of one kind**.+    public func match(nameKey: String, kind: RecordKind) -> MatchTarget? {+        RecordMatching.match(nameKey: nameKey, among: records(of: kind))     } }  /// 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).+/// two records). /// /// 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 {+/// retained key, then alias key, lowest record UUID within a tier. Proposed+/// aliases deliberately take no part (Q93). The caller has already narrowed the+/// targets to one kind; there is no cross-kind tier and never will be (Q13).+public enum RecordMatching {     public static func match(-        nameKey: String, among characters: [CharacterMatchTarget]-    ) -> CharacterMatchTarget? {+        nameKey: String, among records: [MatchTarget]+    ) -> MatchTarget? {         guard !nameKey.isEmpty else { return nil }-        let tiers: [(CharacterMatchTarget) -> Bool] = [+        let tiers: [(MatchTarget) -> Bool] = [             { $0.currentNameKey == nameKey },             { $0.retainedKey == nameKey },             { $0.aliasKeys.contains(nameKey) },         ]         for tier in tiers {-            let matches = characters.filter(tier)+            let matches = records.filter(tier)             if let best = matches.min(by: { $0.id.uuidString < $1.id.uuidString }) { return best }         }         return nil@@ -151,24 +185,37 @@ public enum CharacterMatching { /// 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 {+public enum DecisionAction: 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 struct DecisionRequest: Sendable, Equatable {     public var workID: UUID-    public var action: CharacterDecisionAction+    /// The kind the row **displayed** at decision time, which is the kind the+    /// commit writes, matches and suppresses under (Q24, Q28).+    public var kind: RecordKind+    /// Every kind the pass returned this name under **whose copy survived the+    /// per-kind filter** (Req 1.5). A copy the filter dropped is excluded (Q30,+    /// Q33, Q68), so a name the model returned twice can still arrive here+    /// under one kind.+    ///+    /// The pipeline always fills it — `ExtractionProposal` defaults it to+    /// `[kind]` and the assembler writes `[key.kind]` for every plain row — so+    /// two of them means a Req 1.5 union row and nothing else. See+    /// `suppressedKinds`.+    public var returnedKinds: Set<RecordKind>+    public var action: DecisionAction     /// 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).+    /// The record the row was shown against, or nil for a row displayed as a+    /// new candidate. Re-verified at commit (Q66), under `kind`.     public var displayedTargetID: UUID?-    /// The proposed name, as shown. Mints the new character's name and key when+    /// The proposed name, as shown. Mints the new record's name and key when     /// the row is a candidate.     public var proposedName: String     /// The unstruck proposed aliases, installed on acceptance (Q92/Q96).@@ -176,28 +223,32 @@ public struct CharacterDecisionRequest: Sendable, Equatable {     /// 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+    /// *proposal's* name; the commit re-keys them to the resolved record's     /// retained key (Q79).-    public var facts: [CharacterFact]+    public var facts: [RecordFact]     /// The unticked facts, whose identity triples are suppressed (Req 2.4).-    public var untickedFacts: [CharacterFactIdentity]+    public var untickedFacts: [RecordFactIdentity]     /// 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 var completedSources: [CompletedSource]      public init(         workID: UUID,-        action: CharacterDecisionAction,+        kind: RecordKind = .character,+        returnedKinds: Set<RecordKind> = [],+        action: DecisionAction,         displayedKeys: [String] = [],         displayedTargetID: UUID? = nil,         proposedName: String = "",         proposedAliases: [String] = [],-        facts: [CharacterFact] = [],-        untickedFacts: [CharacterFactIdentity] = [],-        completedSources: [CharacterCompletedSource] = []+        facts: [RecordFact] = [],+        untickedFacts: [RecordFactIdentity] = [],+        completedSources: [CompletedSource] = []     ) {         self.workID = workID+        self.kind = kind+        self.returnedKinds = returnedKinds         self.action = action         self.displayedKeys = displayedKeys         self.displayedTargetID = displayedTargetID@@ -207,10 +258,47 @@ public struct CharacterDecisionRequest: Sendable, Equatable {         self.untickedFacts = untickedFacts         self.completedSources = completedSources     }++    /// The kinds a name-key suppression is written under when this row is+    /// skipped with no target (Req 2.4, Q23).+    ///+    /// Derived rather than stored, so a caller that sets `kind` after+    /// construction cannot leave the two disagreeing.+    ///+    /// The rule in words: **only a Req 1.5 union row suppresses under both+    /// kinds.** A row the pass returned under one kind suppresses under the+    /// kind it was *decided* under — `kind`, which moves when the reader+    /// reclassifies (Req 2.2) — and under that alone, so a character-only row+    /// reclassified to place and then skipped leaves the character key free. A+    /// dual-kind row is one thing skipped once, and the reader skipped the name+    /// under both; `kind` joins them because a reclassification can point it at+    /// a kind outside `returnedKinds`.+    public var suppressedKinds: Set<RecordKind> {+        returnedKinds.count > 1 ? returnedKinds.union([kind]) : [kind]+    }++    /// The kinds a name-key suppression actually reached (Q56, Q77): empty for+    /// anything but a candidate skip.+    ///+    /// `suppressedKinds` answers "which kinds *would* a name-key suppression be+    /// written under", and it is never empty — a bundle skip and an accept both+    /// get a non-empty answer from it even though neither writes one. This is+    /// the gated reading, and it is the only one a cross-kind sweep may use: an+    /// accepted union row's `suppressedKinds` names both kinds, and sweeping on+    /// it would discard a legitimate same-name row of the other kind that+    /// another source produced, which Req 2.4 forbids.+    ///+    /// One accessor rather than the gate repeated at each consumer, so the+    /// repository's other-kind write and the coordinator's held-row sweep cannot+    /// drift apart or reach for the ungated field by accident (Q77).+    public var nameKeySuppressedKinds: Set<RecordKind> {+        guard action == .skip, displayedTargetID == nil else { return [] }+        return suppressedKinds+    } }  /// A source and the revision a decision was derived from.-public struct CharacterCompletedSource: Sendable, Equatable, Hashable {+public struct CompletedSource: Sendable, Equatable, Hashable {     public let ref: SourceRef     public let fingerprint: String @@ -221,24 +309,24 @@ public struct CharacterCompletedSource: Sendable, Equatable, Hashable { }  /// Why a decision wrote nothing.-public enum CharacterDecisionRefusal: Sendable, Equatable {+public enum DecisionRefusal: 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+    /// Q66: the proposal no longer resolves onto the record the reader was     /// shown — including a row displayed as new that now matches an existing-    /// character. The refreshed list re-presents it correctly.+    /// record of the **same kind**. 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.+    /// Req 2.8: the work, or the record the proposal resolves onto, is torn.     /// Acceptance is refused; skipping and unticking are not.-    case torn(characterID: UUID?)+    case torn(recordID: 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+public enum DecisionOutcome: Sendable, Equatable {+    /// The record the decision wrote to or created; nil for a skip, which     /// writes only system records.-    case committed(characterID: UUID?)-    case refused(CharacterDecisionRefusal)+    case committed(recordID: UUID?)+    case refused(DecisionRefusal) }
Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift Modified +126 / -31
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swiftindex 36f6b1b..8783d19 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift@@ -240,14 +240,14 @@ enum DuplicateReconciler {         types: WorkTypeDirectory     ) throws -> PassResult {         var result = PassResult()-        // Character keys are retained like every other type's. Without them a-        // pass would evict the settled state of every character set it just+        // Record keys are retained like every other type's, both kinds. Without+        // them a pass would evict the settled state of every record 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.characterSets.map(\.key)))+                + scan.characterSets.map(\.key) + scan.placeSets.map(\.key)))          result.outcome.formUnion(             try convergeRules(scan, context: context, saveStrategy: saveStrategy))@@ -265,27 +265,38 @@ enum DuplicateReconciler {         result.deletions += entryPhase.deletions         result.canonicalWorkIDs = entryPhase.canonicalWorkIDs +        // Both record kinds converge where character convergence has always run,+        // inside the duplicate phase and in one order (design §Convergence).         result.outcome.formUnion(-            try convergeCharacterGroups(-                scan.characterSets, context: context, saveStrategy: saveStrategy))+            try convergeRecordGroups(+                CharacterRecord.self, scan.characterSets,+                context: context, saveStrategy: saveStrategy))+        result.outcome.formUnion(+            try convergeRecordGroups(+                Place.self, scan.placeSets,+                context: context, saveStrategy: saveStrategy))          return result     } -    // MARK: - Req 6.4/6.5: character groups+    // MARK: - Req 6.4/6.5: record 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).+    /// Makes every row of a same-UUID record 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+    /// because a record 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],+    ///+    /// One body for both kinds (Decision 3): the fetch is the conformance's, and+    /// nothing here knows which table it is writing to.+    private static func convergeRecordGroups<Row: RecordRow>(+        _ rowType: Row.Type,+        _ sets: [RecordDuplicateSet],         context: ModelContext,         saveStrategy: any RepositorySaveStrategy     ) throws -> DuplicateReconciliationOutcome {@@ -296,12 +307,13 @@ enum DuplicateReconciler {         let resolvable = sets.filter { $0.classification == .silentlyResolvable }         guard !resolvable.isEmpty else { return outcome } -        let rowsByID = try LibraryRepository.characterRows(+        let rowsByID = try LibraryRepository.recordRows(+            Row.self,             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] ?? []),+                  let group = LibraryRepository.recordGroup(id: id, rows: rowsByID[id] ?? []),                   !group.isTorn             else { continue }             let content = group.presentedContent@@ -1091,12 +1103,20 @@ enum DuplicateReconciler {                 of: try context.fetch(FetchDescriptor<WorkCredit>()))             var staged: [DuplicateDeletionPlan] = []             var doomedEntries: [Entry] = []-            for plan in chunk-            where try stage(-                plan, rows: &rows, canonicalWorkIDs: canonicalWorkIDs, types: types,-                distinctPairs: distinctPairs, links: links, creditsByWork: &creditsByWork,-                creators: creators, doomedEntries: &doomedEntries, context: context)-            {+            // The place tables, read once for the chunk's Entry plans rather+            // than once per plan — see `PlaceRepointRows` for why it is loaded+            // at the first Entry plan instead of here.+            var placeRows = PlaceRepointRows()+            for (index, plan) in chunk.enumerated() {+                if plan.key.recordType == .entry, !placeRows.isLoaded {+                    try placeRows.load(for: chunk[index...], rows: rows, context: context)+                }+                guard try stage(+                    plan, rows: &rows, canonicalWorkIDs: canonicalWorkIDs, types: types,+                    distinctPairs: distinctPairs, links: links, creditsByWork: &creditsByWork,+                    creators: creators, placeRows: placeRows, doomedEntries: &doomedEntries,+                    context: context)+                else { continue }                 staged.append(plan)             }             guard !staged.isEmpty else { continue }@@ -1119,10 +1139,19 @@ enum DuplicateReconciler {                 of: try context.fetch(FetchDescriptor<WorkCredit>()))             for plan in staged {                 var replayed: [Entry] = []+                // Per plan here, deliberately: the replay commits one plan at a+                // time and a failure rolls back, so its rows have to be read+                // after that rollback like every other row this loop touches.+                var replayPlaceRows = PlaceRepointRows()+                if plan.key.recordType == .entry {+                    try replayPlaceRows.load(+                        for: CollectionOfOne(plan), rows: rows, context: context)+                }                 guard try stage(                     plan, rows: &rows, canonicalWorkIDs: canonicalWorkIDs, types: types,                     distinctPairs: distinctPairs, links: links, creditsByWork: &creditsByWork,-                    creators: creators, doomedEntries: &replayed, context: context)+                    creators: creators, placeRows: replayPlaceRows, doomedEntries: &replayed,+                    context: context)                 else { continue }                 delete(entries: replayed, context: context)                 if try commitDeletion(@@ -1219,6 +1248,7 @@ enum DuplicateReconciler {         links: [WorkLink],         creditsByWork: inout [UUID: [WorkCredit]],         creators: CreatorDirectory,+        placeRows: PlaceRepointRows,         doomedEntries: inout [Entry],         context: ModelContext     ) throws -> Bool {@@ -1230,18 +1260,30 @@ enum DuplicateReconciler {             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+            // record (Q85). Before the deletion, while the works are still             // reachable through the rows about to go.             //+            // **Both kinds in the same throwing scope**, so a character fetch+            // that fails rolls the place rewrite back with it: half a repoint+            // is a library citing a row that is about to go. The place rows+            // were read for the whole chunk (`PlaceRepointRows`); the character+            // ones come through the work's inverse and cost no fetch.+            //             // 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)+            let survivors = Dictionary(+                uniqueKeysWithValues: plan.loserIDs.map { ($0, plan.survivorID) })+            let works = touched.compactMap(\.work)+            let stamp = touched.map(\.modifiedAt).max() ?? .distantPast+            try CitationRepointing.repoint(+                CharacterRecord.self, CharacterSuppression.self,+                survivors: survivors, in: works, context: context, timestamp: stamp)+            CitationRepointing.repoint(+                rows: placeRows.rows(of: works),+                suppressions: placeRows.suppressions(of: works),+                survivors: survivors, timestamp: stamp)             for id in plan.loserIDs {                 doomedEntries += rows.entries[id] ?? []             }@@ -1269,11 +1311,11 @@ 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.+        case .character, .place:+            // Unreachable: a record 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 record bucketing has to be looked at here.             return false         }     }@@ -1310,6 +1352,59 @@ enum DuplicateReconciler {         }     } +    /// The place rows the Entry arm's Req 3.6 repointing rewrites, read **once+    /// per chunk** and bucketed by owning work.+    ///+    /// `Place` and `PlaceSuppression` declare no relationship, so each is+    /// reached by a predicate over `workID` — two store fetches every time a+    /// plan asks for them. `commitDeletions`' standing rule applies: staging a+    /// chunk saves nothing, so one read serves every plan in it, exactly as+    /// `distinctPairs`, `links` and `creditsByWork` are read. The character+    /// tables need no equivalent: they come through `Work.characters`, which is+    /// an inverse traversal rather than a fetch.+    ///+    /// It is loaded at the chunk's **first Entry plan** rather than at the top+    /// of the chunk because a Work plan staged earlier in the same chunk can+    /// re-point an Entry onto a survivor with a different UUID, which would move+    /// the works this read is scoped to. `run` appends every Work deletion+    /// before every Entry deletion (see `commitDeletions`), so by the time the+    /// first Entry plan is reached no further Work plan of the chunk can move+    /// one.+    private struct PlaceRepointRows {+        private var rowsByWork: [UUID: [Place]] = [:]+        private var suppressionsByWork: [UUID: [PlaceSuppression]] = [:]+        private(set) var isLoaded = false++        /// Reads the works of `plans`' Entry rows in one fetch per table.+        mutating func load(+            for plans: some Sequence<DuplicateDeletionPlan>,+            rows: DeletionRows,+            context: ModelContext+        ) throws {+            isLoaded = true+            let works = plans+                .filter { $0.key.recordType == .entry }+                .flatMap(\.key.memberIDs)+                .flatMap { rows.entries[$0] ?? [] }+                .compactMap(\.work)+            guard !works.isEmpty else { return }+            rowsByWork = Dictionary(+                grouping: try Place.rows(of: works, context: context), by: \.workID)+            suppressionsByWork = Dictionary(+                grouping: try PlaceSuppression.rows(of: works, context: context), by: \.workID)+        }++        /// One plan's slice of the chunk's read. Work rows are deduplicated by+        /// UUID: a split Work group hands over several rows for one work.+        func rows(of works: [Work]) -> [Place] {+            Set(works.map(\.id)).flatMap { rowsByWork[$0] ?? [] }+        }++        func suppressions(of works: [Work]) -> [PlaceSuppression] {+            Set(works.map(\.id)).flatMap { suppressionsByWork[$0] ?? [] }+        }+    }+     /// The rows the deletion phase verifies and deletes, fetched by application     /// UUID rather than by loading the tables whole.     private struct DeletionRows {
Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift Modified +19 / -8
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swiftindex c4c1bac..bde182f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift@@ -36,7 +36,7 @@ public enum DuplicateResolutionField: String, Sendable, Equatable, CaseIterable     /// pair, because the reader chooses a place in a series rather than an     /// identifier and a number separately.     case series-    // Character+    // Record (both kinds — one vocabulary, because the shape is one shape)     case name     case aliases     case facts@@ -123,13 +123,15 @@ public struct WorkVariantChoice: Sendable, Equatable, Identifiable {     } } -/// One character variant as the sheet shows it (Req 6.5, Q87).+/// One record variant as the sheet shows it (Req 6.5, Q87) — **one type for+/// both kinds**, because a place variant is told apart by exactly the same four+/// things a character variant is (Q53). /// /// 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 struct RecordVariantChoice: Sendable, Equatable, Identifiable {     public let id: VariantID     public let name: String     public let note: String@@ -171,20 +173,29 @@ public enum DuplicateResolutionContract: Sendable, Equatable {         preselected: VariantID)     case character(         setKey: DuplicateSetKey,-        variants: [CharacterVariantChoice],+        variants: [RecordVariantChoice],+        differingFields: [DuplicateResolutionField],+        preselected: VariantID)+    /// V13. The same payload as `.character`: what differs is the table the+    /// commit writes to, which the set key already says.+    case place(+        setKey: DuplicateSetKey,+        variants: [RecordVariantChoice],         differingFields: [DuplicateResolutionField],         preselected: VariantID)      public var setKey: DuplicateSetKey {         switch self {-        case .entry(let key, _, _, _), .work(let key, _, _, _), .character(let key, _, _, _): key+        case .entry(let key, _, _, _), .work(let key, _, _, _), .character(let key, _, _, _),+             .place(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), .character(_, _, _, let id): id+        case .entry(_, _, _, let id), .work(_, _, _, let id), .character(_, _, _, let id),+             .place(_, _, _, let id): id         }     } @@ -194,14 +205,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)+        case .character(_, let variants, _, _), .place(_, let variants, _, _): variants.map(\.id)         }     }      public var differingFields: [DuplicateResolutionField] {         switch self {         case .entry(_, _, let fields, _), .work(_, _, let fields, _),-             .character(_, _, let fields, _): fields+             .character(_, _, let fields, _), .place(_, _, let fields, _): fields         }     } 
Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift Modified +68 / -38
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swiftindex a2e443e..251fdc7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift@@ -23,6 +23,18 @@ public enum DuplicateRecordType: String, Sendable, Comparable, CaseIterable {     /// 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+    /// V13, appended after `character` for that same reason.+    case place++    /// The scan's name for a record kind. One conversion, here, so no caller+    /// spells the correspondence itself (design §Store generics: three enums,+    /// each layer speaks one).+    init(_ kind: RecordKind) {+        self = switch kind {+        case .character: .character+        case .place: .place+        }+    }      public static func < (lhs: Self, rhs: Self) -> Bool {         (Self.allCases.firstIndex(of: lhs) ?? 0) < (Self.allCases.firstIndex(of: rhs) ?? 0)@@ -155,35 +167,40 @@ public struct DuplicateScanResult: Sendable, Equatable {     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.+    /// see `RecordGroups.swift`.+    public let characterSets: [RecordDuplicateSet]+    /// The same for places, told apart from the character sets by their keys'+    /// `recordType` (Q53) rather than by a second Swift type.+    public let placeSets: [RecordDuplicateSet]++    /// The two record arrays are 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] = []+        characterSets: [RecordDuplicateSet] = [],+        placeSets: [RecordDuplicateSet] = []     ) {         self.entrySets = entrySets         self.workSets = workSets         self.titleRuleSets = titleRuleSets         self.urlRuleSets = urlRuleSets         self.characterSets = characterSets+        self.placeSets = placeSets     }      public var isEmpty: Bool {         entrySets.isEmpty && workSets.isEmpty && titleRuleSets.isEmpty && urlRuleSets.isEmpty-            && characterSets.isEmpty+            && characterSets.isEmpty && placeSets.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+            + characterSets.count + placeSets.count     }      /// The Definitions' assignment normalisation for this scan (Q38).@@ -260,7 +277,6 @@ public enum DuplicateScan {         var workRows: [WorkRow] = []         var patternRows: [RuleRow] = []         var urlRuleRows: [RuleRow] = []-        var characterRows: [CharacterRecord] = []          if !entryComponents.candidates.isEmpty {             let candidates = entryComponents.candidates@@ -290,24 +306,9 @@ 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)-            }-        }+        // Both record tables through one walk each — see `splitRecordRows`.+        let characterRows = try splitRecordRows(CharacterRecord.self, context: context)+        let placeRows = try splitRecordRows(Place.self, context: context)          // Req 1.5: Work sets first. Entry classification reads their survivors,         // both to normalise assignments and to spot the Req 1.6 blockages.@@ -322,7 +323,8 @@ public enum DuplicateScan {             workSets: workSets,             titleRuleSets: buildRuleSets(patternRows, type: .titleRule),             urlRuleSets: buildRuleSets(urlRuleRows, type: .urlRule),-            characterSets: characterSets(of: characterRows))+            characterSets: recordSets(of: characterRows),+            placeSets: recordSets(of: placeRows))     }      // MARK: - The assignment normalisation (Q38)@@ -411,30 +413,34 @@ public enum DuplicateScan {             divergentWorkSetKeysByMember: divergentWorkSetKeys(workSets))     } -    /// The character sets a set of rows implies: **application UUID only**-    /// (Q76).+    /// The record sets a set of rows implies: **application UUID only** (Q76),+    /// over either conformance.     ///     /// 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+    /// names more than one of them — a split group. Two distinct-UUID records     /// 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+    ///+    /// The set's record type comes from the row type's own kind, so no caller+    /// can pair a table with another table's key.+    static func recordSets<Row: RecordRow>(of rows: [Row]) -> [RecordDuplicateSet] {+        let recordType = DuplicateRecordType(Row.kind)+        return Dictionary(grouping: rows, by: \.recordID)+            .compactMap { id, rows -> RecordDuplicateSet? in                 guard rows.count > 1 else { return nil }-                let sorted = GroupOrdering.sortedCharacterRows(rows)+                let sorted = GroupOrdering.sortedRecordRows(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]),+                    key: DuplicateSetKey(recordType: recordType, 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.+                    // Entry set's assignment target, and a record has none.                     classification: member.variants.count > 1                         ? .divergent : .silentlyResolvable)             }@@ -698,6 +704,30 @@ public enum DuplicateScan {     /// `uuidString`, which builds a fresh `String` per comparison, so a     /// duplicate-free 5,000-Entry library paid a full-table O(n log n) sort per     /// full-tier pass before the filter threw every component away (Req 10.2).+    /// One record table's split groups, on the two-walk gate of Req 10.2.+    ///+    /// Records relate by application UUID alone (Q76), so there is no bucket key+    /// and the first walk reads the id column and nothing else. A library with+    /// no split group of that kind never pays for the second walk. The rows 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 the group would not have+    /// faulted anyway.+    private static func splitRecordRows<Row: RecordRow>(+        _ type: Row.Type, context: ModelContext+    ) throws -> [Row] {+        let candidates = try candidateComponents(+            FetchDescriptor<Row>(), context: context, id: \.recordID,+            bucketKey: { _ in nil }).candidates+        guard !candidates.isEmpty else { return [] }+        var rows: [Row] = []+        try context.enumerate(FetchDescriptor<Row>(), batchSize: batchSize) { row in+            guard candidates.contains(row.recordID) else { return }+            rows.append(row)+        }+        return rows+    }+     private static func candidateComponents<Model: PersistentModel>(         _ descriptor: FetchDescriptor<Model>,         context: ModelContext,
Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift Modified +11 / -10
diff --git a/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift b/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swiftindex 64e6357..e75330f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift@@ -173,7 +173,7 @@ public struct DuplicateWorkload: Sendable, Equatable {     public init(scan: DuplicateScanResult) {         self.init(             entrySets: scan.entrySets, workSets: scan.workSets,-            characterSets: scan.characterSets)+            characterSets: scan.characterSets, placeSets: scan.placeSets)     }      /// The same, for a caller that derived the two record types' sets from rows@@ -188,7 +188,8 @@ public struct DuplicateWorkload: Sendable, Equatable {     public init(         entrySets: [EntryDuplicateSet],         workSets: [WorkDuplicateSet],-        characterSets: [CharacterDuplicateSet] = []+        characterSets: [RecordDuplicateSet] = [],+        placeSets: [RecordDuplicateSet] = []     ) {         var review: [DuplicateReviewItem] = []         var deferred: [DuplicateReviewItem] = []@@ -222,20 +223,20 @@ 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 {+        // Records of both kinds are reader-authored, so a divergent set is the+        // reader's work and belongs here. Its route is always `.sheet`: a record+        // 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 + placeSets {             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.+                // Unreachable — a record 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)))             }         }
Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swiftindex 2e918e0..e3f33ad 100644--- a/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/EntryCitations.swift@@ -191,7 +191,7 @@ extension Entry {     /// One citation row, as every pass that walks them sees it.     ///     /// It used to be a table of key paths into the model and into-    /// `BackupV11Entry`, hand-enumerated nowhere else. With the citations folded+    /// `BackupV12Entry`, hand-enumerated nowhere else. With the citations folded     /// into one blob the key paths have nothing to point at, so the row is a     /// *value* now — but the seven rows, their order and their labels are     /// unchanged, because `citerHostnames` still keeps the first hostname it
Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift Modified +21 / -20
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swiftindex 795183a..84b4c2f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryProviding.swift@@ -54,42 +54,43 @@ public protocol LibraryProviding: Sendable {     /// those.     func ruleSuggestionCandidates(hostnames: Set<String>?) async throws -> [RuleSuggestionCandidate] -    // MARK: - Character extraction (`character-extraction`)+    // MARK: - Record extraction (`character-extraction`, `place-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.+    /// locked read (Req 1.1, Q78): source fingerprints and their coverage, and —+    /// **per record kind** — the work's records 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(+    func extractionCandidates(         limit: Int, workIDs: Set<UUID>?-    ) async throws -> [CharacterExtractionCandidate]+    ) async throws -> [ExtractionCandidate]      /// 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]+    func advanceCoverage(+        workID: UUID, sources: [CompletedSource]     ) 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+    /// Commits one review-list decision in one save, under `request.kind`,+    /// re-verifying staleness, the displayed match and tornness inside the+    /// transaction (Reqs 2.2, 2.7, 2.8) against that kind's records alone.+    func commitDecision(+        _ request: DecisionRequest+    ) async throws -> DecisionOutcome++    /// Commits an edit session's staged record operations — of either kind, the+    /// conversion included — in one save, in the order the reader performed+    /// them, against per-record bases (Reqs 3.2, 3.3, 3.7, 5.3).+    func commitRecordEdits(+        workID: UUID, operations: [RecordEditOperation]+    ) async throws -> RecordEditOutcome      /// 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,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift Modified +16 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swiftindex 4a9dced..7b05ccb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImport.swift@@ -144,5 +144,21 @@ extension LibraryRepository {             context.insert(row)             row.work = record.workID.flatMap { worksByID[$0] }         }++        // The places, whose ownership is a **column** rather than a+        // relationship: the archive's `workID` is written whether or not it+        // resolves, so the prospective graph holds the orphan of Req 5.5 exactly+        // as the live library will (`place-extraction` Q60).+        for record in payload.places {+            let place = ArchiveRecordBuilders.makePlace(record)+            context.insert(place)+            place.attach(to: worksByID[record.workID], archivedWorkID: record.workID)+        }++        for record in payload.placeSuppressions {+            let row = ArchiveRecordBuilders.makePlaceSuppression(record)+            context.insert(row)+            row.attach(to: worksByID[record.workID], archivedWorkID: record.workID)+        }     } }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift Modified +6 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swiftindex 8fc8b46..fbf55da 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BackupImportGates.swift@@ -42,11 +42,12 @@ extension LibraryRepository {         // `NSUnknownKeyException` on a column the live entity no longer had;         // and V10 *adds* again, so the live shape is no longer even a subset of         // the frozen one. V11 added two `Work` columns *and* two whole tables,-        // and V12 adds three more tables, which a V11 registration could not-        // answer at all. Every one of those is a reason to name the live schema-        // here, and it must be re-checked on every snapshot freeze — this line-        // has now been re-checked at the V10 and V11 freezes and moved to V12.-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        // V12 added three more tables and V13 adds two, which a V12+        // registration could not answer at all. Every one of those is a reason+        // to name the live schema here, and it must be re-checked on every+        // snapshot freeze — this line has now been re-checked at the V10, V11+        // and V12 freezes and moved to V13.+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             schema: schema,             isStoredInMemoryOnly: true,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift Modified +59 / -55
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swiftindex 5290bbf..959af35 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift@@ -4,42 +4,44 @@ import SwiftData  /// Runtime opening of the live library, classified then acted on. ///-/// Every store the app can reach is recorded at V11 or above, and the conversion-/// left is the one `.lightweight` stage `ModelContainer.init` runs — V11 → V12:-/// the sidecar, the V3 reader, the completion pass and every stage below V11 are+/// Every store the app can reach is recorded at V12 or above, and the conversion+/// left is the one `.lightweight` stage `ModelContainer.init` runs — V12 → V13:+/// the sidecar, the V3 reader, the completion pass and every stage below V12 are /// retired (Decision 1; Q2 of `drop-superseded-columns`, Q18 of /// `work-and-reading-status`, Q60 of `series-and-related-works`, Q15 of-/// `work-creators`). What survives is the readiness contract.+/// `work-creators`, Q48 of `place-extraction`). What survives is the readiness+/// contract. /// The app validates with `LibraryValidator` and clears residual evidence; the-/// marker it publishes contains `"12"` (`extensionOpenableMarkerVersion`), the+/// marker it publishes contains `"13"` (`extensionOpenableMarkerVersion`), the /// only version the extension opens (Q14). /// /// An *empty* store is marked ready as soon as it exists, so the app either opens /// a ready library or throws — there is no third state for the reader to resolve.-/// It is marked at `"12"` directly: there is nothing in it to bring forward (Q26).+/// It is marked at `"13"` directly: there is nothing in it to bring forward (Q26). ///-/// **There is one lagging generation: `"11"`.** V12 adds three tables and no+/// **There is one lagging generation: `"12"`.** V13 adds two tables and no /// column at all, and the whole of that is the lightweight stage /// `ModelContainer` runs — so the `.markerLagging` arm opens, validates, and-/// publishes `"12"`, with no data pass and no reconciler. The digit exists even+/// publishes `"13"`, with no data pass and no reconciler. The digit exists even /// though the stage needs no help from it, because it is what keeps the /// *extension* out of a conversion it must never run (Q3): `openContainer` /// passes the migration plan for both roles, and only the marker check stops a /// concurrent share-sheet invocation from performing it. ///-/// `"10"` is **gone** rather than kept beside `"11"`, on the substitution the+/// `"11"` is **gone** rather than kept beside `"12"`, on the substitution the /// schema-migration note allows once the population has passed the old digit.-/// Unlike the `"10"` substitution, which ran a commit ahead of its verification-/// (Q32 and then Q60 of `series-and-related-works`), this one is behind it: the-/// owner confirmed every device on marker `"11"` on 2026-09-07 before the freeze-/// (`work-creators` Q15), so the marker set and `AsterismV12MigrationPlan`'s-/// `[V11, V12]` have never disagreed at this generation.+/// Like the `"11"` substitution and unlike the `"10"` one, which ran a commit+/// ahead of its verification (Q32 and then Q60 of `series-and-related-works`),+/// this one is behind it: the owner confirmed every device on marker `"12"` on+/// 2026-09-10 before the freeze (`place-extraction` Q48), so the marker set and+/// `AsterismV13MigrationPlan`'s `[V12, V13]` have never disagreed at this+/// generation. /// /// A store found on any other digit is refused, naming it, and the recovery is /// the backup archive, exactly as for a store recorded below V5. The two roles /// differ in what they accept and in what they may do about it: the app opens-/// `"11"` and `"12"` (`appOpenableMarkerVersions`) and may create, convert and-/// mark a store; the extension opens `"12"` only and writes nothing.+/// `"12"` and `"13"` (`appOpenableMarkerVersions`) and may create, convert and+/// mark a store; the extension opens `"13"` only and writes nothing. public extension LibraryRepository {     /// The result of evaluating the live library's fixed-path state under an     /// exclusive lease.@@ -47,7 +49,7 @@ public extension LibraryRepository {         case ready(LibraryRecordCounts)     } -    /// Extension-only readiness result. The extension opens only a `"12"` marker.+    /// Extension-only readiness result. The extension opens only a `"13"` marker.     enum ExtensionResult: Equatable, Sendable {         case ready(LibraryRecordCounts)     }@@ -181,7 +183,7 @@ public extension LibraryRepository {                     + "version this build opens; restore from a backup archive")          case .ready:-            // open (nothing to convert at `"12"`) → validate → clear residual+            // open (nothing to convert at `"13"`) → validate → clear residual             // evidence → counts. The marker already records the current             // generation, so nothing is published: this is the only certification             // sequence left, and it writes no marker at all.@@ -194,12 +196,12 @@ public extension LibraryRepository {             return Certification(result: .ready(counts), diagnostics: diagnostics)          case .markerLagging(let generation):-            // open (which adds the three new creator tables) → validate →-            // publish `"12"`.+            // open (which adds the two new place tables) → validate →+            // publish `"13"`.             //             // **No data pass and no reconciler.** The `"7"` arm ran             // `V8PopulationPass` and `MembershipReconciler` because V8 *added*-            // tables and blobs that something had to fill; V12 adds empty+            // tables and blobs that something had to fill; V13 adds empty             // tables and no column at all, so there is nothing to fill and the             // lightweight stage does the whole of it inside             // `ModelContainer.init`. The launch reconcile runs moments later on@@ -207,19 +209,19 @@ public extension LibraryRepository {             //             // **Validation is the gate, and the marker goes after it.**             // There is no pass to certify itself with, so what certifies the-            // conversion is the store validating: a throw here leaves `"11"` on+            // conversion is the store validating: a throw here leaves `"12"` on             // disk, fails the open, and the next open re-enters this arm over a             // store the stage has already converted — which is safe, because             // adding tables that are already there is a no-op.             //             // The residual evidence goes **last**, after the publish rather than             // with the validation: a publish that fails on a full disk leaves-            // the library at `"11"` with its historical marker and sidecar still+            // the library at `"12"` with its historical marker and sidecar still             // beside it, which is the state the next open wants to find.             let container = try openCertificationContainer(configuration, hooks: hooks)             let context = ModelContext(container)             bootstrapLogger.debug(-                "Marker generation \(generation, privacy: .public) is lagging; certifying the V12 conversion")+                "Marker generation \(generation, privacy: .public) is lagging; certifying the V13 conversion")             let diagnostics = try validateStore(context: context)             try publishReadiness(at: configuration.readinessMarkerURL)             clearResidualEvidence(configuration)@@ -238,7 +240,7 @@ public extension LibraryRepository {                 reason: kind.orphanedReason)          case .unmarkedStore:-            // open → counts → refuse if nonempty → publish `"12"`.+            // open → counts → refuse if nonempty → publish `"13"`.             //             // An *empty* unmarked store is the state a crash between store             // creation and the marker leaves, or a `publishReadiness` that@@ -265,10 +267,10 @@ public extension LibraryRepository {             return Certification(result: .ready(counts), diagnostics: .empty)          case .pristine:-            // open (which creates) → save → counts → publish `"12"`.+            // open (which creates) → save → counts → publish `"13"`.             //-            // Certified at `"12"`, the current generation: a store created by-            // these classes is already V12-shaped, so it is born in the state a+            // Certified at `"13"`, the current generation: a store created by+            // these classes is already V13-shaped, so it is born in the state a             // certified library is in (Q26) rather than at the lagging digit             // with a conversion ahead of it.             let container = try openCertificationContainer(configuration, hooks: hooks)@@ -413,20 +415,20 @@ extension LibraryRepository {         var quarantined: [String: LibraryValidationError] { diagnostics.quarantineMap() }     } -    /// Opens the fixed-path store with the live V12 schema and-    /// `AsterismV12MigrationPlan`, which declares `[V11, V12]` and one-    /// lightweight stage: this call is where an installed V11 library is+    /// Opens the fixed-path store with the live V13 schema and+    /// `AsterismV13MigrationPlan`, which declares `[V12, V13]` and one+    /// lightweight stage: this call is where an installed V12 library is     /// converted, and the only place it happens.     ///-    /// **The stage adds, and adds only tables.** V11 → V12 adds `Creator`,-    /// `CreatorRole` and `WorkCredit` and no `Work` column at all, so there is+    /// **The stage adds, and adds only tables.** V12 → V13 adds `Place` and+    /// `PlaceSuppression` and no `Work` column at all, so there is     /// not even an attribute default involved, and no data pass behind it-    /// (`work-creators` Req 11.1). The V10 → V11 stage retired with its-    /// snapshot once every device was confirmed on marker `"11"` (Q15), as the-    /// V9 → V10 stage did before it (Q60) and the V8 → V9 one before-    /// that (Q18).+    /// (`place-extraction` Req 5.6). The V11 → V12 stage retired with its+    /// snapshot once every device was confirmed on marker `"12"` (Q48), as the+    /// V10 → V11 stage did before it (Q15), the V9 → V10 one before that (Q60)+    /// and the V8 → V9 one before that (Q18).     ///-    /// A store recorded below V11 has no stage and is refused here — `classify`+    /// A store recorded below V12 has no stage and is refused here — `classify`     /// already refuses one before any container is constructed (Req 2.9,     /// Decision 1 of `retire-migration-chain`), so the refusal is a second     /// closed door rather than a new one.@@ -441,12 +443,12 @@ extension LibraryRepository {         at storeURL: URL,         mirroring cloudKitDatabase: ModelConfiguration.CloudKitDatabase = .none     ) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.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-            // V12, so the name is nine versions behind, and renaming it buys+            // V13, so the name is ten versions behind, and renaming it buys             // nothing on a path that opens the owner's only library.             "AsterismV3",             schema: schema,@@ -455,7 +457,7 @@ extension LibraryRepository {         )         return try ModelContainer(             for: schema,-            migrationPlan: AsterismV12MigrationPlan.self,+            migrationPlan: AsterismV13MigrationPlan.self,             configurations: [storeConfiguration]         )     }@@ -464,7 +466,7 @@ extension LibraryRepository {     /// either role opens, and the only version production publishes (Q32).     ///     /// Unversioned by name on purpose: it always writes the generation the build-    /// certifies at, and the digit has moved six times already.+    /// certifies at, and the digit has moved nine times already.     public static func publishReadiness(at url: URL) throws {         do {             try Data("\(extensionOpenableMarkerVersion)\n".utf8).write(to: url, options: .atomic)@@ -550,7 +552,7 @@ extension LibraryRepository {         try? FileManager.default.removeItem(at: configuration.migrationSidecarURL)     } -    /// Store-level validation on the open path — over V12, the live schema.+    /// Store-level validation on the open path — over V13, the live schema.     /// States outside Req 1.1 still     /// fail closed; the four tolerated states and every illegal Site tuple come     /// back as diagnoses, so the library opens and quarantines what it must@@ -577,21 +579,23 @@ extension LibraryRepository {     /// `"7"`. `multi-site-works` added `"7"` back beside `"8"`, and every bump     /// since has **substituted** rather than added: `drop-superseded-columns`     /// put `"8"` in `"7"`'s place (Q2), `work-and-reading-status` put `"9"` in-    /// `"8"`'s (Q18), `series-and-related-works` put `"10"` in `"9"`'s, and-    /// `work-creators` puts `"11"` in `"10"`'s, each time because a lagging arm+    /// `"8"`'s (Q18), `series-and-related-works` put `"10"` in `"9"`'s,+    /// `work-creators` put `"11"` in `"10"`'s, and `place-extraction` puts+    /// `"12"` in `"11"`'s, each time because a lagging arm     /// no device can reach is a path nothing tests. That substitution is what     /// `docs/agent-notes/schema-migration.md` allows only after re-verifying the     /// population.     ///-    /// **At the previous bump the substitution ran ahead of the verification**-    /// (Q32 of `series-and-related-works`): the marker set moved to+    /// **Once, the substitution ran ahead of the verification** (Q32 of+    /// `series-and-related-works`): the marker set moved to     /// `["10", "11"]` while `AsterismV11MigrationPlan` kept the V9 → V10     /// stage, because the prerequisite confirming every device past `"9"` was     /// still unticked, and the follow-up closed the gap a commit later (Q60).-    /// **At this one the verification came first**: the owner confirmed every-    /// device on `"11"` on 2026-09-07 (`work-creators` Q15), so the set moved to-    /// `["11", "12"]` and `AsterismV12MigrationPlan` shipped as `[V11, V12]` in-    /// the same commit. A `"10"` marker is refused by a build that could not+    /// **At the two bumps since, the verification came first**: the owner+    /// confirmed every device on `"11"` on 2026-09-07 (`work-creators` Q15) and+    /// on `"12"` on 2026-09-10 (`place-extraction` Q48), so the set moved to+    /// `["12", "13"]` and `AsterismV13MigrationPlan` shipped as `[V12, V13]` in+    /// the same commit. An `"11"` marker is refused by a build that could not     /// convert its store anyway; the recovery is the backup archive, as for any     /// unrecognised marker.     static let appOpenableMarkerVersions: Set<String> = [@@ -599,16 +603,16 @@ extension LibraryRepository {     ]      /// The one lagging generation the app still opens: a library certified by a-    /// V11 build, which `.markerLagging` converts and re-marks. Frozen persisted+    /// V12 build, which `.markerLagging` converts and re-marks. Frozen persisted     /// state, like its successor below.-    static let laggingOpenableMarkerVersion = "11"+    static let laggingOpenableMarkerVersion = "12"      /// The only version the **extension** opens, and the one `publishReadiness`     /// writes (Q14). Frozen persisted state — these are the bytes on disk in an     /// installed library (Req 3.5). Both generations the app opens are now     /// spelled with two characters, which is why nothing anywhere may assume a     /// marker is one character long.-    static let extensionOpenableMarkerVersion = "12"+    static let extensionOpenableMarkerVersion = "13"      // The app-side counterpart of `validateMarkerContentForExtension` stood     // here. It restated the acceptance test the classifier performs, and@@ -634,7 +638,7 @@ extension LibraryRepository {     /// the fork back the moment it opened two. `multi-site-works` is that     /// moment.     ///-    /// * A generation the app *does* open — `"11"`, the update window: the app is+    /// * A generation the app *does* open — `"12"`, the update window: the app is     ///   updated and not yet launched, the library still records the previous     ///   digit, and opening the app completes the conversion. The message says     ///   so, and `configurable-work-types` Req 8.7 requires the capture to fail
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift Modified +26 / -20
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swiftindex 78ba576..eb40ef4 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift@@ -16,28 +16,33 @@ enum BootstrapState: Equatable, Sendable {     /// migration that would raise it is gone, and the recovery is the backup     /// archive.     ///-    /// **The floor is V11, not V5** — the plan is `[V11, V12]` — so V5 through-    /// V10 stores are equally beyond raising. The name is inherited from when V5+    /// **The floor is V12, not V5** — the plan is `[V12, V13]` — so V5 through+    /// V11 stores are equally beyond raising. The name is inherited from when V5     /// *was* the floor and is kept deliberately (Q35 of     /// `drop-superseded-columns`); the refusal it stands for has widened under     /// it. What has *not* widened is the reading that reaches this case:     /// `StoreMetadata` positively identifies only a below-V5 store, because     /// `NSStoreModelVersionIdentifiers` is advisory and a reader that refused     /// on anything it did not recognise would lock the owner's only library.-    /// A V5–V10 store is therefore refused a row later, by its retired marker-    /// digit (`"5"` through `"10"` all fall to `.unrecognised`), with the+    /// A V5–V11 store is therefore refused a row later, by its retired marker+    /// digit (`"5"` through `"11"` all fall to `.unrecognised`), with the     /// same recovery. Both paths refuse; only this one names a store version.     case belowV5(version: String)     /// A certified library: the readiness marker records the current-    /// generation, `"12"`, and a store is present.+    /// generation, `"13"`, and a store is present.     case ready-    /// A library certified at the **previous** generation, `"11"`, with a store-    /// present: V12's schema stage adds three empty tables and no `Work` column+    /// A library certified at the **previous** generation, `"12"`, with a store+    /// present: V13's schema stage adds two empty tables and no `Work` column     /// at all on the way in, which is the whole of the conversion, so all this     /// arm owes is validating it and republishing. The app does that; the-    /// extension refuses and says to open the app (`work-creators` Req 11.3),-    /// which is what keeps the conversion out of a process that holds a shared-    /// lock (Q3).+    /// extension refuses and says to open the app, which is what keeps the+    /// conversion out of a process that holds a shared lock (Q3).+    ///+    /// **There is no per-generation case.** `markerLaggingV4`/`V5`/`V6` were+    /// retired with the digits they named (`data-model-cleanups` Decision 2);+    /// the generation travels as this case's payload, so a bump moves the+    /// constants in `LibraryRepository+Bootstrap.swift` and this arm's wording+    /// rather than the shape of this enum.     case markerLagging(generation: String)     /// Evidence that a library existed, with no store file of any kind to go with     /// it. Refused so the evidence survives for a restore (Req 2.6).@@ -83,13 +88,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 `"12"` marker is a ready library with a+    /// historical marker beside a valid `"13"` 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 `"12"` and a store present (Req 2.2)-    /// 3. marker `"11"` — a generation the app still opens — and a store present+    /// 2. marker `"13"` and a store present (Req 2.2)+    /// 3. marker `"12"` — a generation the app still opens — and a store present     /// 4. any evidence with no store present (Req 2.6)     /// 5. a store present with no readiness marker of any generation (Req 2.4)     /// 6. nothing on disk (Req 2.5)@@ -100,16 +105,17 @@ extension LibraryRepository {     /// for `"7"`; every bump since substitutes rather than adding a third —     /// `"8"` for `"7"` (Q2 of `drop-superseded-columns`), `"9"` for `"8"`     /// (Q18 of `work-and-reading-status`), `"10"` for `"9"` (Q32 of-    /// `series-and-related-works`), `"11"` for `"10"` (Q15 of `work-creators`)+    /// `series-and-related-works`), `"11"` for `"10"` (Q15 of `work-creators`),+    /// `"12"` for `"11"` (Q48 of `place-extraction`)     /// — because a lagging arm no device can reach is a path nothing tests.     /// Substituting is what `docs/agent-notes/schema-migration.md` permits only     /// after confirming the population has passed the digit that goes; at the     /// `"10"` substitution that confirmation was still outstanding, so     /// `AsterismV11MigrationPlan` kept the V9 → V10 stage the marker set no-    /// longer reached until the follow-up retired it (Q60). At the `"11"`-    /// substitution it came first, so `AsterismV12MigrationPlan` shipped as-    /// `[V11, V12]` in the same commit. A digit outside the set still falls to-    /// the last row and is refused naming itself, with the backup archive as+    /// longer reached until the follow-up retired it (Q60). At the `"11"` and+    /// `"12"` substitutions it came first, so `AsterismV13MigrationPlan` ships+    /// as `[V12, V13]` in the same commit. A digit outside the set still falls+    /// to the last row and is refused naming itself, with the backup archive as     /// the recovery.     ///     /// Store presence is the disjunction over the SQLite family — `.sqlite`,@@ -147,7 +153,7 @@ extension LibraryRepository {         // (Decision 5). Absent, unreadable, merged and unrecognised readings all         // fall through to the marker's word, which is what ships today. The         // reading only ever names a *below-V5* store even though the plan's-        // floor is now V11 (Q35): a V5–V10 store falls through to row 7 on its+        // floor is now V12 (Q35): a V5–V11 store falls through to row 7 on its         // retired marker digit, which refuses it just the same.         if case .below(let version) = StoreMetadata.recordedVersion(at: storeURL) {             return .belowV5(version: version)@@ -244,7 +250,7 @@ extension LibraryRepository {         case .version(let version):             // The shipped wording of the app-side marker check, plus the digit             // itself: this arm catches every retired generation, `"4"` through-            // `"10"`, and a refusal that did not say which one it found would+            // `"11"`, and a refusal that did not say which one it found would             // leave the reader with nothing to act on.             return "marker declares an unsupported schema version "                 + "\"\(abbreviatedMarkerText(version))\"; restore from a backup archive"
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift Deleted +0 / -504
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swiftdeleted file mode 100644index b7aee96..0000000--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift+++ /dev/null@@ -1,504 +0,0 @@-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+ConfirmImport.swift Modified +26 / -24
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swiftindex 495e757..8693ddf 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+ConfirmImport.swift@@ -17,13 +17,13 @@ public struct InterruptedImportReport: Codable, Sendable, Equatable {     } } -/// The one field the off-host pre-pass rewrites. `BackupV11Membership`'s+/// The one field the off-host pre-pass rewrites. `BackupV12Membership`'s /// properties are `let`, so the value is rebuilt through the memberwise /// initialiser rather than mutated — which is what keeps the record a value the /// commit path can treat as verbatim.-extension BackupV11Membership {-    fileprivate func withWorkURLString(_ value: String?) -> BackupV11Membership {-        BackupV11Membership(+extension BackupV12Membership {+    fileprivate func withWorkURLString(_ value: String?) -> BackupV12Membership {+        BackupV12Membership(             id: id, workID: workID, hostname: hostname, createdAt: createdAt,             urlIdentity: urlIdentity, urlIdentityState: urlIdentityState,             urlIdentityRuleID: urlIdentityRuleID, workURLString: value)@@ -195,7 +195,7 @@ extension LibraryRepository {         // (Decision 10), because the archive's own rules are part of what makes         // its designation legal: a `.taught` restore is legal precisely because         // the active title rule arrives in this same step.-        var matchedRecords: [BackupV11Site] = []+        var matchedRecords: [BackupV12Site] = []         for record in payload.sites {             if sitesByHostname[record.hostname] != nil {                 matchedRecords.append(record)@@ -407,14 +407,16 @@ extension LibraryRepository {             try saveStrategy.save(context)         } -        // (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). An archive carrying none of the-        // three skips the step and its save, like the type merge above.+        // (4) The named records of both kinds, their suppressions and their+        // coverage — after the Works and Entries they attach to, so a record's+        // work reference and a coverage pair's source are both already in the+        // store (`character-extraction` Req 6.1, `place-extraction` Req 5.1).+        // An archive carrying none of them skips the step and its save, like the+        // type merge above.         if !payload.characters.isEmpty || !payload.suppressions.isEmpty+            || !payload.places.isEmpty || !payload.placeSuppressions.isEmpty             || payload.carriesCoverage {-            try mergeImportedCharacters(+            try mergeImportedRecords(                 payload, workTargets: workTargets, workRows: workRows,                 entryRows: entryRows, context: context)             try saveStrategy.save(context)@@ -489,7 +491,7 @@ extension LibraryRepository {     /// Every write is guarded by a comparison, so re-importing the same archive     /// dirties nothing — the same property `SiteReconciler.applyUnion` keeps.     internal static func applyDesignation(-        _ record: BackupV11Site,+        _ record: BackupV12Site,         to site: Site,         patterns: [TitlePattern],         urlRules: [URLRulePattern]@@ -538,7 +540,7 @@ extension LibraryRepository {     /// Work's site presence either, and a group skipped as torn must not be     /// half-updated through its memberships.     private static func commitWorks(-        _ records: [BackupV11Work],+        _ records: [BackupV12Work],         into workRows: inout [UUID: [Work]],         types: WorkTypeDirectory,         context: ModelContext,@@ -611,7 +613,7 @@ extension LibraryRepository {     /// Inserting stays unconditional: a row the library does not hold cannot be     /// regressed, and the upsert's posture is to add (Req 4.1).     private static func commitMemberships(-        _ records: [BackupV11Membership],+        _ records: [BackupV12Membership],         workRows: [UUID: [Work]],         workTargets: [UUID: Work],         appliedWorkIDs: Set<UUID>,@@ -718,10 +720,10 @@ extension LibraryRepository {     /// [1.7]: ../../../../specs/wrong-host-work-url-heal/requirements.md#17     /// [1.8]: ../../../../specs/wrong-host-work-url-heal/requirements.md#18     internal static func normalizedMemberships(-        _ records: [BackupV11Membership],+        _ records: [BackupV12Membership],         existingMembershipIDs: Set<UUID>,         appliedWorkIDs: Set<UUID>-    ) -> [BackupV11Membership] {+    ) -> [BackupV12Membership] {         var indicesByWork: [UUID: [Int]] = [:]         for (index, record) in records.enumerated() {             guard let workID = record.workID else { continue }@@ -784,7 +786,7 @@ extension LibraryRepository {     /// library — the insert branch, which is unconditional, or the update     /// branch, which is gated on the Work's record having been applied.     private static func willWriteMembership(-        _ record: BackupV11Membership,+        _ record: BackupV12Membership,         existingMembershipIDs: Set<UUID>,         appliedWorkIDs: Set<UUID>     ) -> Bool {@@ -808,7 +810,7 @@ extension LibraryRepository {     /// re-importing the same archive must write the same values back, which is     /// what makes a repeated import change nothing (Req 13.3).     private static func commitSeries(-        _ records: [BackupV11Series],+        _ records: [BackupV12Series],         context: ModelContext,         batchSize: Int,         saveStrategy: any RepositorySaveStrategy@@ -845,7 +847,7 @@ extension LibraryRepository {     /// is inserted as it stands: an unresolved link is the tolerated state of     /// Req 11.2, and the reader's to remove.     private static func commitLinks(-        _ records: [BackupV11Link],+        _ records: [BackupV12Link],         context: ModelContext,         batchSize: Int,         saveStrategy: any RepositorySaveStrategy@@ -892,7 +894,7 @@ extension LibraryRepository {     /// A credit naming a work, creator or role this library does not hold is     /// inserted as it stands (Req 9.5, 10.2), exactly as an unresolved link is.     private static func commitCredits(-        _ records: [BackupV11Credit],+        _ records: [BackupV12Credit],         context: ModelContext,         batchSize: Int,         saveStrategy: any RepositorySaveStrategy@@ -935,7 +937,7 @@ extension LibraryRepository {     /// reconciler's latest-wins rule reads — so an older archive cannot undo a     /// newer dismissal, and re-importing the same archive writes nothing.     private static func commitDistinctPairs(-        _ records: [BackupV11DistinctPair],+        _ records: [BackupV12DistinctPair],         context: ModelContext,         batchSize: Int,         saveStrategy: any RepositorySaveStrategy@@ -966,7 +968,7 @@ extension LibraryRepository {     /// The mutable half of an archive Work record, shared by the upsert and by     /// the materializers so an inserted record and an updated one cannot drift     /// apart. Identity, hostname, and `createdAt` are set at construction.-    internal static func apply(_ record: BackupV11Work, to work: Work) {+    internal static func apply(_ record: BackupV12Work, to work: Work) {         work.displayTitle = record.displayTitle         work.lastParsedTitle = record.lastParsedTitle         work.genericNotes = record.genericNotes@@ -995,7 +997,7 @@ extension LibraryRepository {     /// The mutable half of an archive membership record. Identity, hostname and     /// `createdAt` are set at construction, so what an update moves is the     /// site-specific content: the URL identity triple and the confirmed Work URL.-    internal static func apply(_ record: BackupV11Membership, to membership: WorkSiteMembership) {+    internal static func apply(_ record: BackupV12Membership, to membership: WorkSiteMembership) {         membership.hostname = record.hostname         membership.createdAt = record.createdAt         membership.urlIdentity = record.urlIdentity@@ -1005,7 +1007,7 @@ extension LibraryRepository {         membership.workID = record.workID ?? membership.workID     } -    internal static func apply(_ record: BackupV11Entry, to entry: Entry) {+    internal static func apply(_ record: BackupV12Entry, to entry: Entry) {         entry.captureTitle = record.captureTitle         entry.captureTitleSourceRaw = record.captureTitleSource.rawValue         entry.rawURLString = record.rawURL
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift Modified +72 / -25
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swiftindex 371df30..b42d5fb 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift@@ -156,7 +156,15 @@ extension LibraryRepository {                 else {                     return (.invalidated(reason: Self.setGoneReason), [], nil)                 }-                return try self.resolveCharacterSet(set, chosen: chosen, context: context)+                return try self.resolveRecordSet(+                    CharacterRecord.self, set, chosen: chosen, context: context)+            case .place:+                guard case .found(let set) = Self.matching(contract.setKey, in: scan.placeSets)+                else {+                    return (.invalidated(reason: Self.setGoneReason), [], nil)+                }+                return try self.resolveRecordSet(+                    Place.self, 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.@@ -168,14 +176,15 @@ extension LibraryRepository {         let (result, losers, resolvedKey) = outcome         if case .committed(let survivorID) = result {             // 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`+            // as `.work`, so a record resolution would have recorded its+            // collapse in the Work redirect map. Dead today — `resolveRecordSet`             // 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+                case .place: .place                 // Rule sets never reach here: the switch above returns                 // `.invalidated` for them, so there is no collapse to record.                 case .titleRule, .urlRule: nil@@ -269,7 +278,21 @@ extension LibraryRepository {                     .character(                         setKey: set.key,                         variants: set.variants.map(Self.choice),-                        differingFields: Self.differingCharacterFields(set.variants),+                        differingFields: Self.differingRecordFields(set.variants),+                        preselected: leading.id))+            }+        case .place:+            switch matching(setKey, in: scan.placeSets) {+            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(+                    .place(+                        setKey: set.key,+                        variants: set.variants.map(Self.choice),+                        differingFields: Self.differingRecordFields(set.variants),                         preselected: leading.id))             }         case .titleRule, .urlRule:@@ -373,9 +396,9 @@ extension LibraryRepository {     }      private static func choice(-        _ variant: AuthoredVariant<CharacterAuthoredContent>-    ) -> CharacterVariantChoice {-        CharacterVariantChoice(+        _ variant: AuthoredVariant<RecordAuthoredContent>+    ) -> RecordVariantChoice {+        RecordVariantChoice(             id: variant.id,             name: variant.content.name,             note: variant.content.note,@@ -384,8 +407,8 @@ extension LibraryRepository {             firstCapturedAt: variant.firstCapturedAt)     } -    private static func differingCharacterFields(-        _ variants: [AuthoredVariant<CharacterAuthoredContent>]+    private static func differingRecordFields(+        _ variants: [AuthoredVariant<RecordAuthoredContent>]     ) -> [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.@@ -535,11 +558,19 @@ extension LibraryRepository {         // 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)+        // false-tear a record (Q85).+        //+        // **Both kinds in the same throwing scope**, so a failed place fetch+        // rolls the character rewrite back with it rather than leaving half a+        // repoint behind.+        let survivors = Dictionary(uniqueKeysWithValues: losers.map { ($0, survivorID) })+        let touchedWorks = allRows.compactMap(\.work)+        try CitationRepointing.repoint(+            CharacterRecord.self, CharacterSuppression.self,+            survivors: survivors, in: touchedWorks, context: context, timestamp: timestamp)+        try CitationRepointing.repoint(+            Place.self, PlaceSuppression.self,+            survivors: survivors, in: touchedWorks, context: context, timestamp: timestamp)          for id in losers {             for row in rowsByID[id] ?? [] {@@ -772,24 +803,26 @@ extension LibraryRepository {         return urls     } -    // MARK: - Commit: character groups+    // MARK: - Commit: record groups -    /// Resolves a torn character group onto one variant (Req 6.5, Q87).+    /// Resolves a torn record group of either kind 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+    /// Nothing is deleted. A record 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+    private func resolveRecordSet<Row: RecordRow>(+        _ rowType: Row.Type,+        _ set: RecordDuplicateSet, 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] ?? []+        let rows = try Self.recordRows(Row.self, ids: [id], context: context)[id] ?? []         guard !rows.isEmpty else {             return (.invalidated(reason: "The surviving copy no longer exists."), [], nil)         }@@ -806,16 +839,30 @@ extension LibraryRepository {             row.modifiedAt = timestamp         } -        // The character's Work's primary site (Req 1.2): a character has no-        // hostname of its own, and the site is only needed to name the-        // quarantine key the resolution validates against.-        let hostname = rows.compactMap { $0.work?.primaryMembership?.hostname }.first ?? ""+        // The record's Work's primary site (Req 1.2): a record has no hostname+        // of its own, and the site is only needed to name the quarantine key the+        // resolution validates against. Reached through `ownerWorkID` rather+        // than a relationship, so the same line answers for both tables — and an+        // orphan (Req 5.5) resolves to no work and validates nothing, which is+        // what a record belonging to no site should do.+        // Sorted, and the pick walks the sorted ids rather than the fetch:+        // neither `Set` iteration nor SwiftData's row order is stable across+        // runs, and two devices must name the same site for the same set.+        let ownerIDs = Set(rows.compactMap(\.ownerWorkID))+            .sorted { $0.uuidString < $1.uuidString }+        let works = ownerIDs.isEmpty+            ? []+            : try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { ownerIDs.contains($0.id) }))+        let hostname = ownerIDs.lazy+            .compactMap { id in works.first(where: { $0.id == id })?.primaryMembership?.hostname }+            .first ?? ""         if let refusal = try commitResolution(             context: context, hostnames: [hostname], operation: "resolution") {             return (refusal, [], nil)         }         resolutionLogger.debug(-            "Resolved torn character group \(id.uuidString) onto one variant")+            "Resolved torn \(Row.kind.rawValue, privacy: .public) group \(id.uuidString) onto one variant")         return (.committed(survivorID: id), [], set.key)     } 
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift Modified +45 / -20
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swiftindex b5583c5..5aef70a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift@@ -232,28 +232,23 @@ extension LibraryRepository {             }              // 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+            // surface): a work's records 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] = []+            //+            // Both kinds, from one fetch of the work (`place-extraction` Req+            // 4.4): the two lists are two sections of one read, and resolving+            // them separately is how they would come to disagree about which+            // work the entry belongs to.+            var citingCharacters: [EntryCitingRecord] = []+            var citingPlaces: [EntryCitingRecord] = []             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-                }+                let works = try context.fetch(+                    FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+                citingCharacters = try Self.citingRecords(+                    CharacterRecord.self, of: works, entryID: id, context: context)+                citingPlaces = try Self.citingRecords(+                    Place.self, of: works, entryID: id, context: context)             }              // With no Site row, or a Site row whose tuple is illegal (Q39), there@@ -281,13 +276,43 @@ extension LibraryRepository {                 workDisplayTitle: workDisplayTitle,                 hasCurrentURLRule: legalSite?.currentURLRule != nil,                 groupState: group.state,-                citingCharacters: citingCharacters+                citingCharacters: citingCharacters,+                citingPlaces: citingPlaces             )         }     }      // MARK: - Private helpers +    /// One kind's records holding a fact that cites `entryID`, in name order+    /// (Req 5.4, `place-extraction` Req 4.4).+    ///+    /// 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. The fetch is+    /// the conformance's — an inverse walk for characters, one predicate for+    /// places — so nothing here reads the whole table.+    ///+    /// Name order, never prominence order: the work page's ranked lists stop at+    /// this list (`character-ranking` Q8), and `localizedStandardCompare` is+    /// what puts "Bay 2" before "Bay 10".+    private static func citingRecords<Row: RecordRow>(+        _ rowType: Row.Type, of works: [Work], entryID: UUID, context: ModelContext+    ) throws -> [EntryCitingRecord] {+        var citing: [EntryCitingRecord] = []+        for group in recordGroups(try Row.rows(of: works, context: context)).values {+            let count = group.presentedContent.facts.count { $0.source == .entry(entryID) }+            guard count > 0 else { continue }+            citing.append(+                EntryCitingRecord(+                    id: group.id, name: group.presentedContent.name, factCount: count))+        }+        return citing.sorted {+            $0.name == $1.name+                ? $0.id.uuidString < $1.id.uuidString+                : $0.name.localizedStandardCompare($1.name) == .orderedAscending+        }+    }+     /// The presentation title for an entry: articles clean junk suffixes,     /// Work-only Sites trim the retained affixes. The immutable capture title     /// stays visible as evidence in provenance disclosure.
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swiftindex 9003fcd..0a742d6 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Export.swift@@ -188,7 +188,7 @@ extension LibraryRepository {             if let group {                 // The group's carrier content, so a split work presents the same                 // title in the export as on its own screen (Req 1.9).-                // An **empty** series directory, for `mapV11WorkRecord`'s+                // An **empty** series directory, for `mapV12WorkRecord`'s                 // reason: the two fields read off this snapshot are the title                 // and the type label, and the directory only ever fills                 // `series` — the membership label, which an entry block does not
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swift Added +787 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swiftnew file mode 100644index 0000000..1d117c3--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordEditing.swift@@ -0,0 +1,787 @@+import Foundation+import SwiftData++// The edit-mode half of records (Req 3.2, 3.3, 3.4, 3.7, 5.3), over both kinds.+//+// One repository call commits the whole session's changes against per-record+// bases (Q73): staged operations apply **in the order the reader performed+// them** (Q97), and any basis mismatch refuses the whole step naming the record,+// leaving the editor open. Guard-and-stay is `commitEditing()`'s existing shape,+// and a partial commit would be unreviewable.+//+// **One call carries both kinds' operations** (Q47). Two calls could not make+// Decision 1's conversion — a delete of one table and a create in another —+// atomic, and the in-order contract above is per session rather than per table:+// the reader performed one sequence, not one per collection.++/// What a record looked like when its editor opened. Content-derived, so a basis+/// taken on one device compares against a row synced from another.+public struct RecordEditBasis: Sendable, Equatable {+    /// Which table this basis was taken from. Every operation's bases must agree+    /// about it (`.kindMismatch`); the conversion is the one operation that+    /// deliberately names two.+    public let kind: RecordKind+    public let recordID: 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(+        kind: RecordKind, recordID: UUID, name: String, note: String, aliases: [String],+        facts: [RecordFact]+    ) {+        self.kind = kind+        self.recordID = recordID+        self.name = name+        self.note = note+        self.aliases = aliases.sorted()+        factsData = RecordFactCodec.encode(facts)+    }++    internal init(kind: RecordKind, recordID: UUID, content: RecordAuthoredContent) {+        self.kind = kind+        self.recordID = recordID+        name = content.name+        note = content.note+        aliases = content.aliases+        factsData = content.factsData+    }++    internal func matches(_ content: RecordAuthoredContent) -> Bool {+        name == content.name && note == content.note && aliases == content.aliases+            && factsData == content.factsData+    }+}++/// The reader's intended state for one record.+///+/// 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 RecordDraft: Sendable, Equatable {+    /// The kind the record is under **after** this session's operations: a+    /// converted record's draft carries the destination kind, because the+    /// editor has already moved the line to the other card (Req 3.7).+    public var kind: RecordKind+    public var name: String+    public var note: String+    public var aliases: [String]+    public var facts: [RecordFact]++    public init(+        kind: RecordKind, name: String, note: String = "", aliases: [String] = [],+        facts: [RecordFact] = []+    ) {+        self.kind = kind+        self.name = name+        self.note = note+        self.aliases = aliases+        self.facts = facts+    }+}++/// One staged edit-session operation.+public enum RecordEditOperation: 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 under its own kind (Q44, Req 3.2).+    case create(RecordDraft)+    case update(basis: RecordEditBasis, draft: RecordDraft)+    /// Deletion suppresses the retained, current and alias keys and every+    /// deleted fact's triple (Q50, Req 3.3, Req 3.4).+    case delete(basis: RecordEditBasis)+    /// 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. A combine never crosses kinds (Req 3.3).+    case combine(source: RecordEditBasis, target: RecordEditBasis)+    /// Conversion (Req 3.7, Decision 1). Delete-and-recreate under a **new**+    /// identity, staged in the session and applied by its one Save; `draft` is+    /// the record as it stands at Save, so edits made after the convert ride on+    /// it and no later operation names the id the convert has not minted yet+    /// (Q54).+    case convert(basis: RecordEditBasis, to: RecordKind, draft: RecordDraft)+}++public enum RecordEditRefusal: Sendable, Equatable {+    /// Q73: the whole step refuses and the editor stays, naming the record.+    case basisMismatch(recordID: UUID, name: String)+    /// Req 2.8/5.3: a torn record is read-only until its resolution, and has no+    /// single content a conversion could carry (Q39).+    case torn(recordID: 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 recordGone(recordID: UUID)+    case workGone+    /// An operation whose own kinds disagree: a combine across kinds, an update+    /// whose draft names the other kind, or a conversion to the kind the record+    /// is already under. Unreachable from the views — the editor knows its own+    /// kind and the combine target list is per kind — and refused rather than+    /// guessed at, because either guess would write to a table the reader did+    /// not name.+    case kindMismatch(recordID: UUID)+}++public enum RecordEditOutcome: Sendable, Equatable {+    /// The records the step wrote or created, in the order the operations were+    /// performed. A conversion reports the record it created, which is the row+    /// that survived the operation.+    case committed(recordIDs: [UUID])+    case refused(RecordEditRefusal)+}++/// 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 RecordEditOperation {++    /// The table this operation reads its group from. A conversion's is the+    /// source's; the destination is the other one by construction.+    internal var kind: RecordKind {+        switch self {+        case .create(let draft): draft.kind+        case .update(let basis, _): basis.kind+        case .delete(let basis): basis.kind+        case .combine(let source, _): source.kind+        case .convert(let basis, _, _): basis.kind+        }+    }++    /// Whether this is the one operation that writes the other kind's table.+    internal var isConversion: Bool {+        if case .convert = self { return true }+        return false+    }++    /// The refusal where the operation's own kinds disagree, or nil.+    internal var kindMismatch: RecordEditRefusal? {+        switch self {+        case .create, .delete:+            nil+        case .update(let basis, let draft):+            draft.kind == basis.kind ? nil : .kindMismatch(recordID: basis.recordID)+        case .combine(let source, let target):+            source.kind == target.kind ? nil : .kindMismatch(recordID: source.recordID)+        case .convert(let basis, let destination, _):+            // The draft takes no part: `to` is the authority, and a draft+            // arriving under either kind describes the same content.+            destination == basis.kind ? .kindMismatch(recordID: basis.recordID) : nil+        }+    }+}++/// Which records this step has already written to, per table (Q108).+private struct RecordTouchKey: Hashable {+    let kind: RecordKind+    let id: UUID+}++extension LibraryRepository {++    /// Commits an edit session's record operations, of either kind, 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 `commitDecision` 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 commitRecordEdits(+        workID: UUID, operations: [RecordEditOperation]+    ) async throws -> RecordEditOutcome {+        guard !operations.isEmpty else { return .committed(recordIDs: []) }+        return try await withLockedContext(+            mode: .exclusive, operation: "committing record 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) }++            // Both tables **when a conversion is staged**, whether or not this+            // session touched both: a conversion writes the one the reader did+            // not open, and reading it lazily would mean a second read inside+            // the same transaction. With no conversion staged, an operation only+            // ever reads and writes its own kind, so a kind no operation names+            // is skipped — a session editing one card no longer pays for the+            // other card's table.+            // Each fetch is the conformance's — one inverse walk, one predicate.+            let kinds: Set<RecordKind> = operations.contains(where: \.isConversion)+                ? Set(RecordKind.allCases)+                : Set(operations.map(\.kind))+            var characterGroups: [UUID: RecordGroup<CharacterRecord>] = [:]+            var characterSuppressions: [CharacterSuppression] = []+            if kinds.contains(.character) {+                characterGroups = Self.recordGroups(+                    try CharacterRecord.rows(of: workRows, context: context))+                characterSuppressions = try CharacterSuppression.rows(+                    of: workRows, context: context)+            }+            var placeGroups: [UUID: RecordGroup<Place>] = [:]+            var placeSuppressions: [PlaceSuppression] = []+            if kinds.contains(.place) {+                placeGroups = Self.recordGroups(try Place.rows(of: workRows, context: context))+                placeSuppressions = try PlaceSuppression.rows(of: workRows, context: context)+            }++            let timestamp = MillisecondInstant.quantize(self.clock.now())+            var written: [UUID] = []+            // Q108: which records this step has already written to, per kind. 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<RecordTouchKey> = []++            // 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: RecordEditOutcome+                if let refusal = operation.kindMismatch {+                    result = .refused(refusal)+                } else if case .convert(let basis, _, let draft) = operation {+                    // The one operation that writes two tables, so the one that+                    // takes both halves of the state.+                    switch basis.kind {+                    case .character:+                        result = Self.convert(+                            basis: basis, draft: draft,+                            sourceGroups: &characterGroups,+                            sourceSuppressions: &characterSuppressions,+                            destinationGroups: &placeGroups,+                            destinationSuppressions: &placeSuppressions,+                            touched: &touched, workRows: workRows, timestamp: timestamp,+                            context: context)+                    case .place:+                        result = Self.convert(+                            basis: basis, draft: draft,+                            sourceGroups: &placeGroups,+                            sourceSuppressions: &placeSuppressions,+                            destinationGroups: &characterGroups,+                            destinationSuppressions: &characterSuppressions,+                            touched: &touched, workRows: workRows, timestamp: timestamp,+                            context: context)+                    }+                } else {+                    switch operation.kind {+                    case .character:+                        result = Self.apply(+                            operation, groups: &characterGroups,+                            suppressionRows: &characterSuppressions, touched: &touched,+                            workRows: workRows, timestamp: timestamp, context: context)+                    case .place:+                        result = Self.apply(+                            operation, groups: &placeGroups,+                            suppressionRows: &placeSuppressions, touched: &touched,+                            workRows: workRows, timestamp: timestamp, context: context)+                    }+                }++                switch result {+                case .refused(let refusal):+                    // The whole step, not this operation: a partial commit would+                    // be unreviewable (Q73).+                    context.rollback()+                    return .refused(refusal)+                case .committed(let ids):+                    written += ids+                }+            }++            try self.saveStrategy.save(context)+            return .committed(recordIDs: 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.+    ///+    /// Keyed by (kind, id): the two tables mint their UUIDs independently, so a+    /// touch of one says nothing about the other.+    private static func verifyOnFirstTouch<Row: RecordRow>(+        _ basis: RecordEditBasis, against group: RecordGroup<Row>,+        touched: inout Set<RecordTouchKey>+    ) -> (isFirstTouch: Bool, refusal: RecordEditRefusal?) {+        guard touched.insert(RecordTouchKey(kind: Row.kind, id: group.id)).inserted+        else { return (false, nil) }+        guard basis.matches(group.presentedContent) else {+            return (true, .basisMismatch(recordID: group.id, name: basis.name))+        }+        return (true, nil)+    }++    /// Create, update, delete and combine — the operations that read and write+    /// one table. One body for both kinds (Decision 3); nothing here names one.+    private static func apply<Row: RecordRow, Suppression: SuppressionRow>(+        _ operation: RecordEditOperation,+        groups: inout [UUID: RecordGroup<Row>],+        suppressionRows: inout [Suppression],+        touched: inout Set<RecordTouchKey>,+        workRows: [Work],+        timestamp: Date,+        context: ModelContext+    ) -> RecordEditOutcome {+        switch operation {+        case .create(let draft):+            let key = RecordNameKey.normalize(draft.name)+            let row = Row.make(+                id: UUID(), name: draft.name, nameKey: key, aliases: draft.aliases,+                note: draft.note, facts: draft.facts, timestamp: timestamp, work: nil)+            context.insert(row)+            // Made unattached, inserted, then attached — the ordering a UUID+            // column needs too: `attach` is where a place learns its owner (Q67).+            row.attach(to: workRows.first, archivedWorkID: nil)+            if let group = recordGroup(id: row.recordID, rows: [row]) {+                groups[row.recordID] = 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(RecordTouchKey(kind: Row.kind, id: row.recordID))+            // Q44: a re-created record 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(recordIDs: [row.recordID])++        case .update(let basis, let draft):+            guard let group = groups[basis.recordID] else {+                return .refused(.recordGone(recordID: basis.recordID))+            }+            if group.isTorn {+                return .refused(.torn(recordID: 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 content = resolvedContent(+                draft: draft, basis: basis, current: current, key: group.carrier.nameKey,+                isFirstTouch: 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+                !content.facts.contains { $0.identity == fact.identity }+            }+            write(+                group, name: content.name, note: content.note, aliases: content.aliases,+                facts: content.facts, timestamp: timestamp)+            suppress(+                facts: removed.map(\.identity), workRows: workRows,+                suppressionRows: &suppressionRows, timestamp: timestamp, context: context)+            groups[group.id] = recordGroup(id: group.id, rows: group.rows)+            return .committed(recordIDs: [group.id])++        case .delete(let basis):+            guard let group = groups[basis.recordID] else {+                return .refused(.recordGone(recordID: basis.recordID))+            }+            if group.isTorn {+                return .refused(.torn(recordID: 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 record under the deleted name, and+            // after Decision 4 the aliases own absorbed names' routing and must+            // die with the record.+            for key in deletionKeys(of: group) {+                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(recordIDs: [group.id])++        case .combine(let sourceBasis, let targetBasis):+            guard let source = groups[sourceBasis.recordID] else {+                return .refused(.recordGone(recordID: sourceBasis.recordID))+            }+            guard let target = groups[targetBasis.recordID] else {+                return .refused(.recordGone(recordID: targetBasis.recordID))+            }+            // 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(recordID: 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 record.+            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] = recordGroup(id: target.id, rows: target.rows)+            return .committed(recordIDs: [target.id])++        case .convert(let basis, _, _):+            // Unreachable: `commitRecordEdits` routes a conversion to+            // `convert(basis:draft:…)`, which is the only body holding both+            // tables. Refusing rather than trapping keeps a future caller's+            // mistake a rollback rather than a crash.+            return .refused(.kindMismatch(recordID: basis.recordID))+        }+    }++    // MARK: - Conversion (Req 3.7, Decision 1)++    /// Recreates one record under the other kind, inside the step's one save.+    ///+    /// Delete-and-recreate with a **new** UUID: an application identity has+    /// convergence meaning only within one entity, so reusing it across two+    /// would manufacture a pair of same-UUID rows in two tables that nothing+    /// reconciles (Decision 1). What carries over is the content — name,+    /// aliases, note, facts with their citations and evidence spans — and the+    /// **retained key** (Q34), which is what proposals have been matched against+    /// all along.+    ///+    /// The suppressions are the deletion's half and the acceptance's half, one+    /// per table: the old kind keeps the vanished facts' triples (Req 3.7) but+    /// **no name key** (Q35), so the model repeating its misfiling is not+    /// silenced for ever; the new kind's standing suppressions of the keys and+    /// triples the record now carries are cleared, because the reader has just+    /// said the record belongs there.+    private static func convert<+        Source: RecordRow, SourceSuppression: SuppressionRow,+        Destination: RecordRow, DestinationSuppression: SuppressionRow+    >(+        basis: RecordEditBasis,+        draft: RecordDraft,+        sourceGroups: inout [UUID: RecordGroup<Source>],+        sourceSuppressions: inout [SourceSuppression],+        destinationGroups: inout [UUID: RecordGroup<Destination>],+        destinationSuppressions: inout [DestinationSuppression],+        touched: inout Set<RecordTouchKey>,+        workRows: [Work],+        timestamp: Date,+        context: ModelContext+    ) -> RecordEditOutcome {+        guard let source = sourceGroups[basis.recordID] else {+            return .refused(.recordGone(recordID: basis.recordID))+        }+        // Q39: a torn record has no single content to carry, which is why+        // acceptance refuses a torn target too.+        if source.isTorn {+            return .refused(.torn(recordID: source.id, name: basis.name))+        }+        let (isFirstTouch, refusal) = verifyOnFirstTouch(+            basis, against: source, touched: &touched)+        if let refusal { return .refused(refusal) }++        let current = source.presentedContent+        let retainedKey = source.carrier.nameKey+        // The draft as it stands at Save (Q54), applied to the stored facts the+        // same way an update applies it: the edit surface can move statements+        // and nothing else (Q74), and a fact the draft dropped is a deletion.+        let content = resolvedContent(+            draft: draft, basis: basis, current: current, key: retainedKey,+            isFirstTouch: isFirstTouch)++        let created = Destination.make(+            id: UUID(), name: content.name, nameKey: retainedKey, aliases: content.aliases,+            note: content.note, facts: content.facts, timestamp: timestamp, work: nil)+        context.insert(created)+        created.attach(to: workRows.first, archivedWorkID: nil)+        if let group = recordGroup(id: created.recordID, rows: [created]) {+            destinationGroups[created.recordID] = group+        }+        touched.insert(RecordTouchKey(kind: Destination.kind, id: created.recordID))++        // Req 3.7's "its deleted facts": every fact the original held goes with+        // it, the carried ones included, so the old kind re-proposes none of+        // them. No name-key suppression (Q35).+        suppress(+            facts: current.facts.map(\.identity), workRows: workRows,+            suppressionRows: &sourceSuppressions, timestamp: timestamp, context: context)++        // The destination's clears: the verified basis's retained, current and+        // alias keys, the draft's name and alias keys, and the carried triples.+        // A clear is written only where a row exists, so an over-wide key set+        // costs nothing and a missed key would freeze the converted record out+        // of enrichment.+        var keys: Set<String> = [retainedKey]+        for name in [basis.name, current.name, content.name] {+            keys.insert(RecordNameKey.normalize(name))+        }+        for alias in basis.aliases + current.aliases + content.aliases {+            keys.insert(RecordNameKey.normalize(alias))+        }+        clearSuppressions(+            keys: keys.filter { !$0.isEmpty }.sorted(),+            factIdentities: content.facts.map(\.identity),+            workRows: workRows, suppressionRows: destinationSuppressions,+            timestamp: timestamp, context: context)++        // The source group deletes **whole**, as a combine's does: a proper+        // subset left behind is the partial conversion the fan-out rule exists+        // to prevent.+        for row in source.rows { context.delete(row) }+        sourceGroups[source.id] = nil+        return .committed(recordIDs: [created.recordID])+    }++    // MARK: - Combine (Decision 4)++    private static func combine<Row: RecordRow, Suppression: SuppressionRow>(+        source: RecordGroup<Row>,+        into target: RecordGroup<Row>,+        workRows: [Work],+        suppressionRows: inout [Suppression],+        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(RecordNameKey.normalize)+                + [RecordNameKey.normalize(targetContent.name), targetKey])+        for candidate in [sourceContent.name] + sourceContent.aliases + [source.carrier.nameKey] {+            let key = RecordNameKey.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 ToleratedEnum.read(row.kindRaw, default: CharacterSuppressionKind.candidate)+            == .fact && row.nameKey == sourceKey+            && ToleratedEnum.read(row.statusRaw, default: CharacterSuppressionStatus.active)+            == .active {+            guard let sourceRef = SourceRef(+                kindRaw: row.sourceKindRaw, entryID: row.sourceEntryID),+                  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.statusRaw = CharacterSuppressionStatus.cleared.rawValue+            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++    /// Q50's key set: the retained key, the current name's, and every alias's.+    private static func deletionKeys<Row: RecordRow>(of group: RecordGroup<Row>) -> [String] {+        let content = group.presentedContent+        let keys = Set(+            [group.carrier.nameKey, RecordNameKey.normalize(content.name)]+                + content.aliases.map(RecordNameKey.normalize))+        return keys.filter { !$0.isEmpty }.sorted()+    }++    /// What an update or a conversion writes, from the draft, the basis and the+    /// record as this step has left it.+    ///+    /// Q108's other half: on a record 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 record 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 resolves to the draft's+    /// value and the behaviour is unchanged.+    private static func resolvedContent(+        draft: RecordDraft,+        basis: RecordEditBasis,+        current: RecordAuthoredContent,+        key: String,+        isFirstTouch: Bool+    ) -> (name: String, note: String, aliases: [String], facts: [RecordFact]) {+        (+            name: isFirstTouch || draft.name != basis.name ? draft.name : current.name,+            note: isFirstTouch || draft.note != basis.note ? draft.note : current.note,+            aliases: isFirstTouch || draft.aliases.sorted() != basis.aliases+                ? draft.aliases : current.aliases,+            facts: applyStatements(+                draft.facts, to: current.facts, key: key, deletingOmitted: isFirstTouch)+        )+    }++    /// 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 record 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: [RecordFact], to stored: [RecordFact], key: String,+        deletingOmitted: Bool+    ) -> [RecordFact] {+        var statements: [RecordFactIdentity: 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 RecordFact(+                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<Row: RecordRow>(+        _ group: RecordGroup<Row>,+        name: String,+        note: String,+        aliases: [String],+        facts: [RecordFact],+        timestamp: Date+    ) {+        let bytes = RecordFactCodec.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<Suppression: SuppressionRow>(+        facts identities: [RecordFactIdentity],+        workRows: [Work],+        suppressionRows: inout [Suppression],+        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<Suppression: SuppressionRow>(+        key: String,+        workRows: [Work],+        suppressionRows: inout [Suppression],+        timestamp: Date,+        context: ModelContext+    ) {+        guard !key.isEmpty else { return }+        let existing = suppressionRows.filter {+            ToleratedEnum.read($0.kindRaw, default: CharacterSuppressionKind.candidate)+                == .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+RecordExtraction`): 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<Suppression: SuppressionRow>(+        kind: CharacterSuppressionKind,+        nameKey: String,+        source: SourceRef?,+        evidence: String?,+        status: CharacterSuppressionStatus,+        workRows: [Work],+        suppressionRows: inout [Suppression],+        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+RecordExtraction.swift Renamed +339 / -189
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordExtraction.swiftsimilarity index 53%rename from Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swiftrename to Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordExtraction.swiftindex 7be41e5..00df979 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+RecordExtraction.swift@@ -1,17 +1,20 @@ 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).+// The store half of extraction: one read that supplies the sweep's whole filter+// input (Q78), and one commit per reader decision (Q37) — over both record+// kinds. // // **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.+// covers at pass time through `advanceCoverage`; a source with shown proposals+// covers inside `commitDecision`, 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.+//+// **Coverage is one column per source, never one per kind** (Q9). The kind lives+// on the decision, not on the bookkeeping.  extension LibraryRepository { @@ -20,20 +23,20 @@ extension LibraryRepository {     /// 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).+    /// it, for **both kinds**: the source fingerprints, the stored coverage, the+    /// accepted fact identities, the active suppressions and the existing+    /// records' 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(+    public func extractionCandidates(         limit: Int, workIDs: Set<UUID>? = nil-    ) async throws -> [CharacterExtractionCandidate] {+    ) async throws -> [ExtractionCandidate] {         if let workIDs, workIDs.isEmpty { return [] }         return try await withLockedContext(-            mode: .shared, operation: "reading character extraction candidates"+            mode: .shared, operation: "reading extraction candidates"         ) { context in             let works: [Work]             if let workIDs {@@ -44,40 +47,55 @@ extension LibraryRepository {                 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.+            // The unscoped sweep read stays whole-library on all four 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+            // manual pass and by `reconcile` — and there the whole-table fetches+            // scaled with the library rather than with the work. The character             // 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-                }-            }+            // inverses `CitationRepointing.repoint(in:)` walks; the place rows+            // come from one predicate fetch over every work handed in+            // (`swiftdata-relationships.md` rule 2). Either way it is **one+            // fetch per table for every examined work**, grouped in memory here.+            //+            // An orphan is excluded on both sides: grouping by `work?.id` filed+            // the character one under nil, and a `workID` naming no work matches+            // no group (Req 5.5, Req 6.7).+            //+            // One grouping per table either way, so the four reads say the same+            // thing: the whole table when the sweep asked for every work, the+            // works' own rows when the caller named some.+            let charactersByWork = Dictionary(+                grouping: workIDs == nil+                    ? try context.fetch(FetchDescriptor<CharacterRecord>())+                    : try CharacterRecord.rows(of: works, context: context),+                by: { $0.work?.id })+            let characterSuppressionsByWork = Dictionary(+                grouping: workIDs == nil+                    ? try context.fetch(FetchDescriptor<CharacterSuppression>())+                    : try CharacterSuppression.rows(of: works, context: context),+                by: { $0.work?.id })+            let placesByWork = Dictionary(+                grouping: workIDs == nil+                    ? try context.fetch(FetchDescriptor<Place>())+                    : try Place.rows(of: works, context: context),+                by: \.workID)+            let placeSuppressionsByWork = Dictionary(+                grouping: workIDs == nil+                    ? try context.fetch(FetchDescriptor<PlaceSuppression>())+                    : try PlaceSuppression.rows(of: works, context: context),+                by: \.workID)              return Self.workGroups(works, types: types).values-                .compactMap { group -> CharacterExtractionCandidate? in+                .compactMap { group -> ExtractionCandidate? 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))+                        characters: charactersByWork[group.id] ?? [],+                        characterSuppressions: characterSuppressionsByWork[group.id] ?? [],+                        places: placesByWork[group.id] ?? [],+                        placeSuppressions: placeSuppressionsByWork[group.id] ?? [])                 }                 // Newest activity first, UUID as the tie-break so two devices                 // examine the same works in the same order.@@ -94,8 +112,10 @@ extension LibraryRepository {     private static func extractionCandidate(         _ group: WorkGroup,         characters: [CharacterRecord],-        suppressions: [CharacterSuppression]-    ) -> CharacterExtractionCandidate? {+        characterSuppressions: [CharacterSuppression],+        places: [Place],+        placeSuppressions: [PlaceSuppression]+    ) -> ExtractionCandidate? {         let carrier = group.carrier         let genericNotes = carrier.genericNotes         var sources: [CharacterExtractionSource] = []@@ -137,34 +157,49 @@ extension LibraryRepository {          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(+        let characterHalf = recordHalf(characters)+        let placeHalf = recordHalf(places)+        return ExtractionCandidate(             workID: group.id,             displayTitle: carrier.displayTitle,             recency: recency,             sources: sources,-            characters: targets.sorted { $0.id.uuidString < $1.id.uuidString },-            acceptedFactIdentities: accepted,-            suppressions: suppressionIndex(suppressions))+            records: [.character: characterHalf.targets, .place: placeHalf.targets],+            acceptedFacts: [+                .character: characterHalf.accepted, .place: placeHalf.accepted,+            ],+            suppressions: [+                .character: suppressionIndex(characterSuppressions),+                .place: suppressionIndex(placeSuppressions),+            ])+    }++    /// One kind's match targets and accepted fact triples, from its rows.+    private static func recordHalf<Row: RecordRow>(+        _ rows: [Row]+    ) -> (targets: [MatchTarget], accepted: Set<RecordFactIdentity>) {+        let groups = recordGroups(rows)+        var accepted: Set<RecordFactIdentity> = []+        for group in groups.values {+            for fact in group.presentedContent.facts { accepted.insert(fact.identity) }+        }+        return (+            groups.values.map(matchTarget).sorted { $0.id.uuidString < $1.id.uuidString },+            accepted+        )     }      // 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(+    /// A record 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, for either table.+    internal static func matchTarget<Row: RecordRow>(_ group: RecordGroup<Row>) -> MatchTarget {+        MatchTarget(             id: group.id,-            currentNameKey: CharacterNameKey.normalize(group.presentedContent.name),+            currentNameKey: RecordNameKey.normalize(group.presentedContent.name),             retainedKey: group.carrier.nameKey,-            aliasKeys: group.presentedContent.aliases.map(CharacterNameKey.normalize),+            aliasKeys: group.presentedContent.aliases.map(RecordNameKey.normalize),             isTorn: group.isTorn)     } @@ -174,7 +209,7 @@ extension LibraryRepository {     ///     /// 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+    /// `CitationRepointing.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.@@ -207,6 +242,10 @@ extension LibraryRepository {     /// 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.+    ///+    /// The record kind is **not** part of the key: the two kinds live in two+    /// tables (Decision 2), so a key is only ever compared against rows of one+    /// of them.     private struct SuppressionKey: Hashable {         let kind: CharacterSuppressionKind         let nameKey: String@@ -214,8 +253,8 @@ extension LibraryRepository {         let sourceEntryID: UUID?         let evidence: String? -        init(_ row: CharacterSuppression) {-            kind = row.kind+        init<Row: SuppressionRow>(_ row: Row) {+            kind = ToleratedEnum.read(row.kindRaw, default: .candidate)             nameKey = row.nameKey             sourceKind = row.sourceKindRaw             sourceEntryID = row.sourceEntryID@@ -238,13 +277,13 @@ extension LibraryRepository {     /// 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+    /// cleared re-suppressing is a nuisance and a clear lost is a record     /// 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] = [:]+    private static func resolvedSuppressions<Row: SuppressionRow>(+        _ rows: [Row]+    ) -> [SuppressionKey: Row] {+        var winners: [SuppressionKey: Row] = [:]         for row in rows {             let key = SuppressionKey(row)             guard let current = winners[key] else {@@ -257,31 +296,37 @@ extension LibraryRepository {     }      /// Whether `candidate` is the row the reader's most recent action left.-    private static func suppressionPrecedes(-        _ candidate: CharacterSuppression, _ current: CharacterSuppression+    private static func suppressionPrecedes<Row: SuppressionRow>(+        _ candidate: Row, _ current: Row     ) -> 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+        let candidateStatus = ToleratedEnum.read(candidate.statusRaw, default: CharacterSuppressionStatus.active)+        let currentStatus = ToleratedEnum.read(current.statusRaw, default: CharacterSuppressionStatus.active)+        if candidateStatus != currentStatus { return candidateStatus == .cleared }+        return candidate.recordID.uuidString < current.recordID.uuidString     } -    private static func suppressionIndex(-        _ rows: [CharacterSuppression]-    ) -> CharacterSuppressionIndex {+    private static func suppressionIndex<Row: SuppressionRow>(+        _ rows: [Row]+    ) -> SuppressionIndex {         var candidateKeys: Set<String> = []-        var factIdentities: Set<CharacterFactIdentity> = []-        for (key, row) in resolvedSuppressions(rows) where row.status == .active {+        var factIdentities: Set<RecordFactIdentity> = []+        for (key, row) in resolvedSuppressions(rows)+        where ToleratedEnum.read(row.statusRaw, default: CharacterSuppressionStatus.active)+            == .active {             switch key.kind {             case .candidate:                 candidateKeys.insert(key.nameKey)             case .fact:-                guard let source = row.source, let evidence = row.evidence else { continue }+                guard let source = SourceRef(kindRaw: row.sourceKindRaw, entryID: row.sourceEntryID),+                      let evidence = row.evidence+                else { continue }                 factIdentities.insert(-                    CharacterFactIdentity(+                    RecordFactIdentity(                         nameKey: key.nameKey, source: source, quote: evidence))             }         }-        return CharacterSuppressionIndex(+        return SuppressionIndex(             candidateKeys: candidateKeys, factIdentities: factIdentities)     } @@ -293,13 +338,16 @@ extension LibraryRepository {     /// **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).+    ///+    /// Kind-free by construction (Q9): the columns are the source's, and one+    /// decision of either kind covers the revision for both.     @discardableResult-    public func advanceCharacterCoverage(-        workID: UUID, sources: [CharacterCompletedSource]+    public func advanceCoverage(+        workID: UUID, sources: [CompletedSource]     ) async throws -> Int {         guard !sources.isEmpty else { return 0 }         return try await withLockedContext(-            mode: .exclusive, operation: "advancing character extraction coverage"+            mode: .exclusive, operation: "advancing extraction coverage"         ) { context in             let works = try context.fetch(                 FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))@@ -314,7 +362,7 @@ extension LibraryRepository {     /// text, across every row of the work group.     @discardableResult     private static func writeCoverage(-        _ sources: [CharacterCompletedSource], works: [Work]+        _ sources: [CompletedSource], works: [Work]     ) -> Int {         guard !works.isEmpty else { return 0 }         var written = 0@@ -344,27 +392,31 @@ extension LibraryRepository {      // MARK: - Committing a decision (Req 2.2, 2.7, 2.8) -    /// Commits one review-list decision, in one save.+    /// Commits one review-list decision, in one save, under `request.kind`.     ///     /// 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 proposal still resolves onto the record the reader was **shown** —+    ///   including a row shown as a new candidate that now matches an existing+    ///   record of that kind (Q66, Q24) — and+    /// * neither the work nor that record is torn (Req 2.8, Q48).+    ///+    /// Every one of those is asked **of `request.kind` alone** (Q13). A place+    /// named "Bay" is not a re-route for a character candidate, and a torn place+    /// refuses nothing a character decision asked for.     ///     /// 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 {+    public func commitDecision(+        _ request: DecisionRequest+    ) async throws -> DecisionOutcome {         try await withLockedContext(-            mode: .exclusive, operation: "committing a character decision"+            mode: .exclusive, operation: "committing an extraction decision"         ) { context in             // A local `let` rather than `request.workID` inside the predicate:             // the macro cannot key-path into a captured struct.@@ -376,67 +428,153 @@ extension LibraryRepository {             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)+            let outcome: DecisionOutcome+            switch request.kind {+            case .character:+                outcome = try Self.apply(+                    request, CharacterRecord.self, CharacterSuppression.self,+                    workGroup: workGroup, workRows: workRows, timestamp: timestamp,+                    context: context)+            case .place:+                outcome = try Self.apply(+                    request, Place.self, PlaceSuppression.self,+                    workGroup: workGroup, workRows: workRows, timestamp: timestamp,+                    context: context)             }+            // A refusal wrote nothing, so there is nothing to save and nothing+            // to cover: the gates all run before the first write.+            if case .refused = outcome { return outcome } -            // 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)+            // Q23: a row the pass returned under both kinds and the reader+            // skipped with no target is one thing skipped once, so the name-key+            // suppression is written under **every** kind it was returned under.+            // This is the one write that reaches the other table.+            try Self.suppressUnderOtherKinds(+                request, workRows: workRows, 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)+            return outcome+        }+    }++    /// The whole decision under one kind: the gates, the content write, and the+    /// suppressions and clears that kind owns.+    ///+    /// One body for both tables (Decision 3). Nothing here names a kind; it is+    /// the row and suppression types that differ, and every fetch is theirs.+    private static func apply<Row: RecordRow, Suppression: SuppressionRow>(+        _ request: DecisionRequest,+        _ rowType: Row.Type,+        _ suppressionType: Suppression.Type,+        workGroup: WorkGroup,+        workRows: [Work],+        timestamp: Date,+        context: ModelContext+    ) throws -> DecisionOutcome {+        let records = try Row.rows(of: workRows, context: context)+        let suppressionRows = try Suppression.rows(of: workRows, context: context)+        let groups = recordGroups(records)++        var committedID: UUID?+        switch request.action {+        case .accept:+            // Resolved once and carried: the gate and the write must agree+            // about which record this is, and two resolutions of one proposal+            // are two chances to disagree.+            let target = resolvedTarget(request, groups: groups)+            if let refusal = acceptanceRefusal(+                request, workGroup: workGroup, resolved: target, workRows: workRows) {+                return .refused(refusal)+            }+            committedID = applyAcceptance(+                request, target: target, workRows: workRows,+                suppressionRows: suppressionRows, timestamp: timestamp, context: context)+        case .skip:+            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).+        // Under the row's own kind only — the fact belongs to the record the+        // row displayed (Q13).+        suppressFacts(+            request.untickedFacts, workRows: workRows, suppressionRows: suppressionRows,+            timestamp: timestamp, context: context)+        return .committed(recordID: committedID)+    }++    /// Q23's one cross-table write: the displayed keys suppressed under every+    /// kind the pass returned the row under, other than the one `apply` already+    /// handled.+    ///+    /// Only for a *candidate* skip. A bundle skip never suppresses a name key at+    /// all (Q33, Q47), so there is nothing to spread — which is what+    /// `nameKeySuppressedKinds` says by coming back empty, rather than this+    /// body re-stating the gate (Q77).+    private static func suppressUnderOtherKinds(+        _ request: DecisionRequest,+        workRows: [Work],+        timestamp: Date,+        context: ModelContext+    ) throws {+        for kind in request.nameKeySuppressedKinds where kind != request.kind {+            switch kind {+            case .character:+                let rows = try CharacterSuppression.rows(of: workRows, context: context)+                writeCandidateSuppressions(+                    request.displayedKeys, workRows: workRows, suppressionRows: rows,+                    timestamp: timestamp, context: context)+            case .place:+                let rows = try PlaceSuppression.rows(of: workRows, context: context)+                writeCandidateSuppressions(+                    request.displayedKeys, workRows: workRows, suppressionRows: rows,+                    timestamp: timestamp, context: context)+            }+        }+    }++    private static func writeCandidateSuppressions<Suppression: SuppressionRow>(+        _ keys: [String],+        workRows: [Work],+        suppressionRows: [Suppression],+        timestamp: Date,+        context: ModelContext+    ) {+        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)         }     }      /// The three acceptance gates, in the order the design states them.-    private static func acceptanceRefusal(-        _ request: CharacterDecisionRequest,+    private static func acceptanceRefusal<Row: RecordRow>(+        _ request: DecisionRequest,         workGroup: WorkGroup,-        resolved: CharacterGroup?,+        resolved: RecordGroup<Row>?,         workRows: [Work]-    ) -> CharacterDecisionRefusal? {-        if workGroup.isTorn { return .torn(characterID: nil) }+    ) -> DecisionRefusal? {+        if workGroup.isTorn { return .torn(recordID: 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) }+        if let resolved, resolved.isTorn { return .torn(recordID: resolved.id) }         return nil     }      /// Whether a cited revision still holds the text it was proposed from.     private static func currentlyHolds(-        _ source: CharacterCompletedSource, works: [Work]+        _ source: CompletedSource, works: [Work]     ) -> Bool {         switch source.ref {         case .genericNotes:@@ -455,54 +593,55 @@ extension LibraryRepository {         }     } -    /// Req 2.3's tiers over the work's current characters.+    /// Req 2.3's tiers over the work's current records **of the request's+    /// kind**.     ///-    /// The tiers themselves are `CharacterMatching`'s — the same body the sweep's+    /// The tiers themselves are `RecordMatching`'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+    /// routing one proposal onto two records). 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 whose alias half names an existing record 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),+    private static func resolvedTarget<Row: RecordRow>(+        _ request: DecisionRequest, groups: [UUID: RecordGroup<Row>]+    ) -> RecordGroup<Row>? {+        guard let matched = RecordMatching.match(+            nameKey: RecordNameKey.normalize(request.proposedName),             among: groups.values.map(matchTarget))         else { return nil }         return groups[matched.id]     } -    private static func applyAcceptance(-        _ request: CharacterDecisionRequest,-        target: CharacterGroup?,+    private static func applyAcceptance<Row: RecordRow, Suppression: SuppressionRow>(+        _ request: DecisionRequest,+        target: RecordGroup<Row>?,         workRows: [Work],-        suppressionRows: [CharacterSuppression],+        suppressionRows: [Suppression],         timestamp: Date,         context: ModelContext-    ) throws -> UUID? {+    ) -> UUID? {         let retainedKey = target?.carrier.nameKey-            ?? CharacterNameKey.normalize(request.proposedName)-        // Q79: facts are canonicalised to the resolved character's retained key+            ?? RecordNameKey.normalize(request.proposedName)+        // Q79: facts are canonicalised to the resolved record'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+        let recordID: UUID         if let target {-            characterID = target.id+            recordID = 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])+                aliases.map(RecordNameKey.normalize)+                    + [RecordNameKey.normalize(target.presentedContent.name), retainedKey])             for alias in request.proposedAliases-            where !existingKeys.contains(CharacterNameKey.normalize(alias)) {+            where !existingKeys.contains(RecordNameKey.normalize(alias)) {                 aliases.append(alias)             }-            let bytes = CharacterFactCodec.encode(facts)+            let bytes = RecordFactCodec.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 {@@ -511,30 +650,37 @@ extension LibraryRepository {                 row.modifiedAt = timestamp             }         } else {-            let character = CharacterRecord(+            // Made unattached, inserted, then attached — the ordering the+            // character path has always used, and the one a UUID column needs+            // too: `attach` is where a place learns its owner (Q67).+            let row = Row.make(+                id: UUID(),                 name: request.proposedName,                 nameKey: retainedKey,                 aliases: request.proposedAliases,+                note: "",                 facts: incoming,-                timestamp: timestamp)-            context.insert(character)-            character.work = workRows.first-            characterID = character.id+                timestamp: timestamp,+                work: nil)+            context.insert(row)+            row.attach(to: workRows.first, archivedWorkID: nil)+            recordID = row.recordID         }          // Req 2.5: accepting clears the standing suppression of the keys the row-        // displayed, and of every fact it accepted.+        // displayed, and of every fact it accepted — under this kind alone, so a+        // reader's decision about the other kind stands (Q13).         clearSuppressions(             keys: request.displayedKeys, factIdentities: incoming.map(\.identity),             workRows: workRows, suppressionRows: suppressionRows, timestamp: timestamp,             context: context)-        return characterID+        return recordID     } -    private static func applySkip(-        _ request: CharacterDecisionRequest,+    private static func applySkip<Suppression: SuppressionRow>(+        _ request: DecisionRequest,         workRows: [Work],-        suppressionRows: [CharacterSuppression],+        suppressionRows: [Suppression],         timestamp: Date,         context: ModelContext     ) {@@ -543,30 +689,29 @@ extension LibraryRepository {             // 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+            // would freeze those facts out of a record 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)-            }+            writeCandidateSuppressions(+                request.displayedKeys, 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.+        // record's name key (Q47/Req 2.4): a name-key suppression blocks new+        // candidates only, and writing one here would freeze an existing record+        // out of enrichment for ever. That holds for a dual-kind bundle too+        // (Q33) — there is an existing record on one side, and its key is not+        // the reader's to suppress.         suppressFacts(             request.facts.map(\.identity), workRows: workRows,             suppressionRows: suppressionRows, timestamp: timestamp, context: context)     } -    private static func suppressFacts(-        _ identities: [CharacterFactIdentity],+    private static func suppressFacts<Suppression: SuppressionRow>(+        _ identities: [RecordFactIdentity],         workRows: [Work],-        suppressionRows: [CharacterSuppression],+        suppressionRows: [Suppression],         timestamp: Date,         context: ModelContext     ) {@@ -578,11 +723,14 @@ extension LibraryRepository {         }     } -    private static func clearSuppressions(+    /// Req 2.5's clears, and the conversion's (Req 3.7): the acceptance path and+    /// the edit step write them the same way, or the two would key a clear+    /// differently.+    internal static func clearSuppressions<Suppression: SuppressionRow>(         keys: [String],-        factIdentities: [CharacterFactIdentity],+        factIdentities: [RecordFactIdentity],         workRows: [Work],-        suppressionRows: [CharacterSuppression],+        suppressionRows: [Suppression],         timestamp: Date,         context: ModelContext     ) {@@ -626,32 +774,34 @@ extension LibraryRepository {     /// 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.+    /// implementation**, over either suppression table: the edit path's `inout`+    /// twin delegates here so the two paths cannot key a suppression+    /// differently.     @discardableResult-    static func writeSuppression(+    static func writeSuppression<Suppression: SuppressionRow>(         kind: CharacterSuppressionKind,         nameKey: String,         source: SourceRef?,         evidence: String?,         status: CharacterSuppressionStatus,         workRows: [Work],-        suppressionRows: [CharacterSuppression],+        suppressionRows: [Suppression],         timestamp: Date,         context: ModelContext-    ) -> CharacterSuppression? {+    ) -> Suppression? {         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)+        guard let winner = existing.min(by: { $0.recordID.uuidString < $1.recordID.uuidString })+        else {+            let row = Suppression.make(+                id: UUID(), work: nil, kind: kind, nameKey: nameKey, source: source,+                evidence: evidence, status: status, actionAt: timestamp)             context.insert(row)-            row.work = workRows.first+            row.attach(to: workRows.first, archivedWorkID: nil)             return row         }-        winner.status = status+        winner.statusRaw = status.rawValue         winner.actionAt = timestamp         return nil     }
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift Modified +3 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swiftindex 754e737..f0257cf 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift@@ -27,11 +27,13 @@ internal enum ResolvedWriteTarget<Group> { internal enum CollapsedRecordType: Sendable, Hashable {     case entry     case work-    /// Present for completeness of the mapping, never populated: a character set+    /// Present for completeness of the mapping, never populated: a record 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+    /// V13, and never populated for the same reason.+    case place }  extension LibraryRepository {
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift Modified +9 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swiftindex d9817bd..a28bc17 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift@@ -177,6 +177,15 @@ extension LibraryRepository {                 for character in row.characterValues { context.delete(character) }                 for suppression in row.characterSuppressionValues { context.delete(suppression) }             }+            // `place-extraction` Req 3.5: the place tables go in the same commit+            // and for the same reason. Reached by **predicate on `workID`** —+            // `Place` declares no relationship at all, and the fetch returns+            // materialized rows, which is what lets the rollback below restore+            // them instead of crashing in snapshot creation.+            for row in try Place.rows(of: group.rows, context: context) { context.delete(row) }+            for row in try PlaceSuppression.rows(of: group.rows, context: context) {+                context.delete(row)+            }             // V8, Req 7.3 and 5.7: the site memberships and every distinct-pair             // record naming the Work go with it, for the same reason. Both are             // deliberately not cascaded by SwiftData — the membership
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift Modified +31 / -5
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swiftindex 6cec9e8..6e47b10 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift@@ -82,12 +82,23 @@ public struct WorkDetailPresentation: Sendable, Equatable {     /// 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]+    public let characters: [WorkRecordPresentation]+    /// `place-extraction` [4.1](../../../../specs/place-extraction/requirements.md#41)–+    /// [4.3](../../../../specs/place-extraction/requirements.md#43): the work's+    /// places, in the prominence order computed over **the work's places alone**+    /// (that spec's Q14), with the same fact derivation the characters get.+    ///+    /// Read in the same locked context as everything else here, through one+    /// predicate fetch over the work group's ids: `Place` declares no+    /// relationship, so there is no inverse to walk, and one fetch answers the+    /// whole page (`swiftdata-relationships.md` rule 2). Empty is the ordinary+    /// case, and what makes the section absent rather than empty (Req 4.1).+    public let places: [WorkRecordPresentation]     /// 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+    /// `WorkRecordPresentation` 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@@ -117,11 +128,13 @@ public struct WorkDetailPresentation: Sendable, Equatable {     public init(         work: WorkSnapshot, pulse: RatingPulse, lastNotedURLString: String?,         chapterRows: [WorkChapterRow],-        characters: [WorkCharacterPresentation] = [],+        characters: [WorkRecordPresentation] = [],+        places: [WorkRecordPresentation] = [],         captureOrder: [UUID: Int] = [:],         links: [WorkLinkSnapshot] = [],         credits: [CreditDisplay] = []     ) {+        self.places = places         self.credits = credits         self.work = work         self.pulse = pulse@@ -263,6 +276,13 @@ extension LibraryRepository {             // 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)+            // The places, by predicate over the group's ids rather than through+            // an inverse: `Place` declares no relationship at all, so one fetch+            // answers every row of the work group and the grouping happens here+            // (`swiftdata-relationships.md` rule 2). A row whose `workID`+            // resolves to no work matches no page's fetch, which is the whole+            // of what a tolerated orphan does (Req 5.5).+            let placeRows = try Place.rows(of: group.rows, context: context)              // Req 8.1's section, through the one derivation the export and the             // merge basis also read (`+WorkLinks.linkSnapshots`).@@ -276,8 +296,14 @@ extension LibraryRepository {                     down: entries.filter { $0.rating == .down }.count),                 lastNotedURLString: entries.first?.rawURLString,                 chapterRows: rows,-                characters: Self.characterPresentations(-                    Self.characterGroups(characterRows), index: storyPositions,+                characters: Self.recordPresentations(+                    Self.recordGroups(characterRows), index: storyPositions,+                    captureOrder: captureOrder, titles: titles, dates: dates, keys: keys),+                // The same derivation over the other table, ranked separately:+                // ranking the two lists independently is what keeps each list's+                // order meaningful (`place-extraction` Q14).+                places: Self.recordPresentations(+                    Self.recordGroups(placeRows), index: storyPositions,                     captureOrder: captureOrder, titles: titles, dates: dates, keys: keys),                 captureOrder: captureOrder,                 links: links,
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift Modified +50 / -14
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swiftindex 237dc15..8e8d2fd 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift@@ -240,7 +240,7 @@ extension LibraryRepository {     /// 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+        from source: WorkGroup, to target: WorkGroup, timestamp: Date     ) {         guard let survivor = target.rows.first else { return }         var moved: [CharacterRecord] = []@@ -253,25 +253,58 @@ extension LibraryRepository {                 suppression.work = survivor             }         }-        for (_, group) in characterGroups(moved) {+        recanonicaliseGenericNotesCitations(of: moved, timestamp: timestamp)+        for row in target.rows {+            row.genericNotesExtractionFingerprint = nil+        }+        for entry in target.rows.flatMap({ $0.entryValues }) {+            entry.characterExtractionFingerprint = nil+        }+    }++    /// `moveCharacters`' place half (`place-extraction` Req 3.5), the same three+    /// writes over a table that names its owner by column.+    ///+    /// The move is a **`workID` rewrite** rather than a pointer move, and that+    /// is the only difference: a place row arriving after the merge keeps the+    /// source id and becomes the tolerated orphan of Req 5.5, where the+    /// character equivalent nullifies to nil. Both are the same race with the+    /// same answer — the reader's record is not lost, and nothing is repaired on+    /// elapsed time.+    private static func movePlaces(+        from source: WorkGroup, to target: WorkGroup, timestamp: Date, context: ModelContext+    ) throws {+        guard let survivor = target.rows.first else { return }+        let moved = try Place.rows(of: source.rows, context: context)+        for row in moved { row.workID = survivor.id }+        for row in try PlaceSuppression.rows(of: source.rows, context: context) {+            row.workID = survivor.id+        }+        recanonicaliseGenericNotesCitations(of: moved, timestamp: timestamp)+    }++    /// The third of `moveCharacters`' three writes, over any record table.+    ///+    /// 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 move cannot leave+    /// the group's bytes disagreeing (Q85).+    ///+    /// Only groups whose presented facts cite the generic notes are re-encoded+    /// and stamped, so an untouched record keeps the `modifiedAt` the archive+    /// carries as its import value guard.+    private static func recanonicaliseGenericNotesCitations<Row: RecordRow>(+        of moved: [Row], timestamp: Date+    ) {+        for (_, group) in recordGroups(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)+            let bytes = RecordFactCodec.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(@@ -504,7 +537,8 @@ extension LibraryRepository {             // 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(+            Self.moveCharacters(from: sourceGroup, to: targetGroup, timestamp: timestamp)+            try Self.movePlaces(                 from: sourceGroup, to: targetGroup, timestamp: timestamp, context: context)              // Delete the source group whole (Req 2.2): a group is deleted@@ -656,6 +690,7 @@ extension LibraryRepository {         let sourceRows = try context.fetch(             FetchDescriptor<Work>(predicate: #Predicate { $0.id == sourceWorkID }))         let sourceCharacters = characterRows(of: sourceRows)+        let sourcePlaces = try Place.rows(of: sourceRows, context: context)         // `series-and-related-works` Req 9.4: both sides' links, so the planner         // can name what the collapse will remove and the commit can tell a link         // that arrived in between from one the reader saw.@@ -666,6 +701,7 @@ extension LibraryRepository {                 rulesByHostname: rulesByHostname,                 unreadableRuleHostnames: unreadableRuleHostnames,                 movedCharacterCount: Set(sourceCharacters.map(\.id)).count,+                movedPlaceCount: Set(sourcePlaces.map(\.recordID)).count,                 sourceLinks: try linkSnapshots(of: sourceWorkID, context: context, types: types),                 targetLinks: try linkSnapshots(of: targetWorkID, context: context, types: types)             ),
Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift Modified +5 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swiftindex 7ea43d9..fa6868d 100644--- a/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/LibraryRepository.swift@@ -651,9 +651,10 @@ public actor LibraryRepository {                 switch plan.key.recordType {                 case .entry: .entry                 case .work: .work-                // 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+                // None ever produces a deletion plan — rules converge and are+                // never deleted (Q39), and a record set of either kind has no+                // losers (Q76).+                case .titleRule, .urlRule, .character, .place: nil                 }             guard let type else { continue }             for loser in plan.loserIDs {@@ -1655,7 +1656,7 @@ public actor LibraryRepository {     // The four `map*Record` mappers stood here — `Entry`/`Work`/`Site`/     // `TitlePattern` to the V2 `*Record` structs, for the snapshot     // `validateStore` validated. They went with it; the live export path has its-    // own `map*Record` family in `BackupArchiveProjection`, over `BackupV11Entry`+    // own `map*Record` family in `BackupArchiveProjection`, over `BackupV12Entry`     // and friends, and never used these.      internal func withLockedContext<Value: Sendable>(
Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swiftindex 7d85f10..8566e8f 100644--- a/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/MembershipReconciler.swift@@ -359,7 +359,7 @@ enum MembershipReconciler {     /// alone lands one ULP away from the same instant built through     /// `MillisecondInstant`, and the archive's date encoding quantizes on the way     /// out — so an unquantized `createdAt` decodes back as a *different* `Date`-    /// and `BackupV11Exporter`'s decode-validation refuses the file it just wrote.+    /// and `BackupV12Exporter`'s decode-validation refuses the file it just wrote.     /// A library holding a minted membership could not be backed up at all     /// ([4.2](../../../../specs/wrong-host-work-url-heal/requirements.md#42)).     /// The value stays a pure function of stored content, so two devices healing
Packages/AsterismCore/Sources/AsterismCore/Models.swift Modified +189 / -32
diff --git a/Packages/AsterismCore/Sources/AsterismCore/Models.swift b/Packages/AsterismCore/Sources/AsterismCore/Models.swiftindex d0d0612..e4cbbf7 100644--- a/Packages/AsterismCore/Sources/AsterismCore/Models.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/Models.swift@@ -1,58 +1,66 @@ import Foundation import SwiftData -// The live model classes are V12's, nested inside `AsterismSchemaV12`+// The live model classes are V13's, nested inside `AsterismSchemaV13` // (Decision 6, Q20). Top-level typealiases keep every call site (`Entry`, // `Site`, …) unchanged. //-// The nesting is what makes the frozen `AsterismSchemaV11` snapshot possible: it+// The nesting is what makes the frozen `AsterismSchemaV12` snapshot possible: it // carries nested classes with the same SwiftData entity names, which is legal // only while there is exactly one // *top-level* `@Model` per entity name — and there are none, because every // top-level name here is a typealias. Two top-level `@Model`s sharing an entity // name crash `ModelContext` (`docs/agent-notes/schema-migration.md`).-public typealias Entry = AsterismSchemaV12.Entry-public typealias Work = AsterismSchemaV12.Work-public typealias Site = AsterismSchemaV12.Site-public typealias TitlePattern = AsterismSchemaV12.TitlePattern-public typealias URLRulePattern = AsterismSchemaV12.URLRulePattern-public typealias WorkTypeEntity = AsterismSchemaV12.WorkTypeEntity+public typealias Entry = AsterismSchemaV13.Entry+public typealias Work = AsterismSchemaV13.Work+public typealias Site = AsterismSchemaV13.Site+public typealias TitlePattern = AsterismSchemaV13.TitlePattern+public typealias URLRulePattern = AsterismSchemaV13.URLRulePattern+public typealias WorkTypeEntity = AsterismSchemaV13.WorkTypeEntity /// V8: a Work's presence on one site (Q3). One row per hostname, carrying that /// site's URL identity, the rule that derived it and the confirmed Work URL — /// and since V9 the only home any of them has.-public typealias WorkSiteMembership = AsterismSchemaV12.WorkSiteMembership+public typealias WorkSiteMembership = AsterismSchemaV13.WorkSiteMembership /// V8: a reader's "not the same work" over an unordered pair of Works (Q20).-public typealias WorkDistinctPair = AsterismSchemaV12.WorkDistinctPair+public typealias WorkDistinctPair = AsterismSchemaV13.WorkDistinctPair /// V11: a reader-named, ordered collection a Work belongs to at most once /// (`series-and-related-works` Decision 1).-public typealias Series = AsterismSchemaV12.Series+public typealias Series = AsterismSchemaV13.Series /// V11: an undirected, typed connection between two distinct Works /// (`series-and-related-works` Decision 5).-public typealias WorkLink = AsterismSchemaV12.WorkLink+public typealias WorkLink = AsterismSchemaV13.WorkLink /// V12: a person a work is credited to, a reader-managed directory record /// (`work-creators` Decision 2).-public typealias Creator = AsterismSchemaV12.Creator+public typealias Creator = AsterismSchemaV13.Creator /// V12: one entry in the ordered role list credits reference by identity /// (`work-creators` Decision 1).-public typealias CreatorRole = AsterismSchemaV12.CreatorRole+public typealias CreatorRole = AsterismSchemaV13.CreatorRole /// V12: one work-and-creator pairing, carrying the roles that creator holds on /// that work (`work-creators` Decision 6).-public typealias WorkCredit = AsterismSchemaV12.WorkCredit+public typealias WorkCredit = AsterismSchemaV13.WorkCredit // `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 = AsterismSchemaV12.Character-public typealias CharacterSuppression = AsterismSchemaV12.CharacterSuppression+public typealias CharacterRecord = AsterismSchemaV13.Character+public typealias CharacterSuppression = AsterismSchemaV13.CharacterSuppression+/// V13: one named location of one work, the second `RecordRow` table+/// (`place-extraction` Decision 3). Unlike `Character` the name collides with+/// nothing in the stdlib, so it is aliased at the top level under its own name.+public typealias Place = AsterismSchemaV13.Place+/// V13: a place's remembered decision not to re-propose, its own record type+/// rather than a column on `CharacterSuppression` (`place-extraction`+/// Decision 2).+public typealias PlaceSuppression = AsterismSchemaV13.PlaceSuppression  /// The presentation-enum tolerance policy for the **model accessors**, in one /// place (Q2). /// /// Not the only coercion in the codebase, and not meant to be: the archive /// paths spell their own `?? .default` — `WorkTypeDirectory`,-/// `ArchiveRecordBuilders`, `BackupV11Types`, `BackupArchiveProjection` — each+/// `ArchiveRecordBuilders`, `BackupV12Types`, `BackupArchiveProjection` — each /// answering what *that* wire may say rather than what a stored column may /// hold. Those are deliberately separate policies, not omissions from this one. ///@@ -108,7 +116,7 @@ enum JSONBlob {     } } -extension AsterismSchemaV12 {+extension AsterismSchemaV13 {  @Model public final class Entry {@@ -885,13 +893,13 @@ 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`.+    /// renames (Q19/Q46). Normalised by `RecordNameKey.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+    /// `[RecordFact]` 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.@@ -911,7 +919,7 @@ public final class Character {         nameKey: String = "",         aliases: [String] = [],         note: String = "",-        facts: [CharacterFact] = [],+        facts: [RecordFact] = [],         timestamp: Date = Date(timeIntervalSince1970: 0),         work: Work? = nil     ) {@@ -920,20 +928,14 @@ public final class Character {         self.nameKey = nameKey         self.aliases = aliases         self.note = note-        factsData = CharacterFactCodec.encode(facts)+        factsData = RecordFactCodec.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) }-    }-+    // `facts` is `RecordRow`'s (RecordRow.swift): the decode is the same for+    // every record table, so it is written once rather than per model. }  /// V7: one remembered decision not to re-propose something (Q60/Q72).@@ -1471,7 +1473,162 @@ public final class WorkCredit {     } } -} // extension AsterismSchemaV12+/// V13: one named place of one work — accepted from an extraction proposal or+/// created by hand (`place-extraction` Decision 3, Q3).+///+/// The shape is `Character`'s, field for field, because the two are one record+/// type to every piece of store code: `RecordRow` is written once and both+/// tables conform (Decision 3). What differs is the single column below.+///+/// **The owning work is a `UUID` column, not a relationship** — the `WorkCredit`+/// shape, and CLAUDE.md's standing rule made a fourth time. An inverse would+/// fault every place of a work to answer a count, and a `.nullify` on an absent+/// target would erase a value that has to survive while the work is still in+/// transit. `workID` is non-optional and defaulted (Q44): the value is always+/// known at insert, and an orphan keeps the id it will re-attach by, so nothing+/// needs to be nullable. Orphan-ness is "no work resolves", never nil (Req 5.5).+///+/// `nameKey` is **retained**: minted once at accept or creation commit and never+/// re-derived from a rename, so a renamed place keeps attracting the proposals+/// that named it before. A place converted from a character carries the+/// character's retained key (Q34).+///+/// Every property is defaulted or optional and nothing is unique: this is a+/// CloudKit-mirrored table like the rest.+@Model+public final class Place {+    public var id: UUID = UUID()+    public var name: String = ""+    /// The key the place was accepted, created or converted under, retained+    /// through renames. Normalised by `RecordNameKey.normalize`.+    public var nameKey: String = ""+    /// Extra match keys, reader-editable and grown by the combine. Stored as the+    /// reader's spellings; matching normalises.+    public var aliases: [String] = []+    public var note: String = ""+    /// `[RecordFact]` in the canonical encoding. 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 place belongs to, by identifier. A value naming no Work is+    /// a **tolerated** orphan: displayed nowhere, never validated, exported+    /// as-is, and found by the predicate the moment the work arrives (Req 5.5).+    public var workID: UUID = UUID()++    /// 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: [RecordFact] = [],+        timestamp: Date = Date(timeIntervalSince1970: 0),+        workID: UUID = UUID()+    ) {+        self.id = id+        self.name = name+        self.nameKey = nameKey+        self.aliases = aliases+        self.note = note+        factsData = RecordFactCodec.encode(facts)+        createdAt = timestamp+        modifiedAt = timestamp+        self.workID = workID+    }++    // `facts` is `RecordRow`'s (RecordRow.swift): the decode is the same for+    // every record table, so it is written once rather than per model.+}++/// V13: one remembered decision not to re-propose a **place**+/// (`place-extraction` Decision 2).+///+/// A separate record type rather than a record-kind column on+/// `CharacterSuppression`, because both configurations mirror to CloudKit and a+/// pre-feature build sharing the container cannot see a new column: to that+/// build a place suppression would be a character suppression with the same+/// converging tuple, and accepting a character would *clear* it (Req 5.2). A+/// record type it does not know is ignored entirely.+///+/// A **system record**, like its character twin: it never tears, never blocks an+/// export, and converges to the reader's most recent action rather than by set+/// union. The two enum columns store the raw strings `CharacterSuppressionKind`+/// and `CharacterSuppressionStatus` spell — the enums are shared across kinds+/// (Q27) — and the defaults name those cases rather than restating their+/// spellings, exactly as `CharacterSuppression` does.+///+/// The work is a `UUID` column for `Place`'s reasons.+@Model+public final class PlaceSuppression {+    public var id: UUID = UUID()+    /// The Work this suppression belongs to, by identifier. An unresolved value+    /// is the same tolerated orphan `Place.workID` describes.+    public var workID: UUID = UUID()+    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.+    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.+    public var evidence: String?+    public var statusRaw: String = CharacterSuppressionStatus.active.rawValue+    /// When the reader acted. The comparable convergence needs.+    public var actionAt: Date = Date(timeIntervalSince1970: 0)++    public init(+        id: UUID = UUID(),+        workID: UUID = UUID(),+        kind: CharacterSuppressionKind = .candidate,+        nameKey: String = "",+        source: SourceRef? = nil,+        evidence: String? = nil,+        status: CharacterSuppressionStatus = .active,+        actionAt: Date = Date(timeIntervalSince1970: 0)+    ) {+        self.id = id+        self.workID = workID+        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 { ToleratedEnum.read(kindRaw, default: .candidate) }+        set { kindRaw = newValue.rawValue }+    }++    public var status: CharacterSuppressionStatus {+        get { ToleratedEnum.read(statusRaw, default: .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 AsterismSchemaV13  /// A rule-derived URL identity a creation site already holds, passed through /// `Work.create` so the minted membership is born carrying it (Req 1.3, 3.1).
Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift Modified +11 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swiftindex e7ae5f0..f460511 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ProjectionContract.swift@@ -660,6 +660,9 @@ public struct WorkMergeBasis: Equatable, Sendable {     /// the preview and the confirmation refreshes the sheet the way an arriving     /// entry does — the reader confirmed a count.     public let movedCharacterCount: Int+    /// The same for places (`place-extraction` Req 3.5), counted the same way:+    /// logical records, so a split place group is one place the merge moves.+    public let movedPlaceCount: Int      public init(         source: WorkMergeWorkBasis,@@ -667,6 +670,7 @@ public struct WorkMergeBasis: Equatable, Sendable {         rulesByHostname: [String: URLRuleBasisEntry?],         unreadableRuleHostnames: Set<String> = [],         movedCharacterCount: Int = 0,+        movedPlaceCount: Int = 0,         sourceLinks: [WorkLinkSnapshot] = [],         targetLinks: [WorkLinkSnapshot] = []     ) throws {@@ -701,6 +705,7 @@ public struct WorkMergeBasis: Equatable, Sendable {         self.rulesByHostname = rulesByHostname         self.unreadableRuleHostnames = unreadableRuleHostnames         self.movedCharacterCount = movedCharacterCount+        self.movedPlaceCount = movedPlaceCount         self.sourceLinks = sourceLinks         self.targetLinks = targetLinks     }@@ -712,6 +717,7 @@ public struct WorkMergeBasis: Equatable, Sendable {         currentRule: URLRuleBasisEntry?,         ruleUnreadable: Bool = false,         movedCharacterCount: Int = 0,+        movedPlaceCount: Int = 0,         sourceLinks: [WorkLinkSnapshot] = [],         targetLinks: [WorkLinkSnapshot] = []     ) throws {@@ -724,6 +730,7 @@ public struct WorkMergeBasis: Equatable, Sendable {             },             unreadableRuleHostnames: ruleUnreadable ? hostnames : [],             movedCharacterCount: movedCharacterCount,+            movedPlaceCount: movedPlaceCount,             sourceLinks: sourceLinks, targetLinks: targetLinks)     } }@@ -860,6 +867,8 @@ public struct WorkMergeOutcome: Equatable, Sendable {     /// 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+    /// The same for places (`place-extraction` Req 3.5).+    public let movedPlaceCount: Int     /// V11: the membership the merged Work will carry — the target's own where     /// it had one, the source's where it did not, nil where neither did     /// (`series-and-related-works` [9.1](../../../../specs/series-and-related-works/requirements.md#91)).@@ -912,6 +921,7 @@ public struct WorkMergeOutcome: Equatable, Sendable {         discardedFields: [WorkMergeField],         sourceDeleted: Bool,         movedCharacterCount: Int = 0,+        movedPlaceCount: Int = 0,         // Defaulted for the same reason `movedCharacterCount` is: an outcome         // built by hand — the app's merge-model tests do — is describing a         // preview, and a caller that omits them is describing a Work on the@@ -937,6 +947,7 @@ public struct WorkMergeOutcome: Equatable, Sendable {         self.readingStatus = readingStatus         self.verdict = verdict         self.movedCharacterCount = movedCharacterCount+        self.movedPlaceCount = movedPlaceCount         self.sourceID = sourceID         self.targetID = targetID         self.displayTitle = displayTitle
Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift Modified +12 / -3
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swiftindex 717b0e4..178488c 100644--- a/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecentPresentation.swift@@ -253,7 +253,14 @@ public struct EntryTeachingDetail: Equatable, Sendable {     /// (`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 let citingCharacters: [EntryCitingRecord]+    /// `place-extraction` [4.4](../../../../specs/place-extraction/requirements.md#44):+    /// the same, for the places holding a fact that cites this entry — a section+    /// separate from the characters one, in name order.+    ///+    /// Populated in the same locked read, from one predicate fetch over the+    /// entry's work: the two lists are one read's answer, not two.+    public let citingPlaces: [EntryCitingRecord]      public init(         entry: EntrySnapshot, siteMode: SiteMode?,@@ -266,10 +273,12 @@ public struct EntryTeachingDetail: Equatable, Sendable {         workDisplayTitle: String? = nil,         hasCurrentURLRule: Bool = false,         groupState: RecordGroupState<EntryAuthoredContent> = .single,-        citingCharacters: [EntryCitingCharacter] = []+        citingCharacters: [EntryCitingRecord] = [],+        citingPlaces: [EntryCitingRecord] = []     ) {         self.groupState = groupState         self.citingCharacters = citingCharacters+        self.citingPlaces = citingPlaces         self.entry = entry         self.displayTitle = displayTitle ?? entry.captureTitle         self.workDisplayTitle = workDisplayTitle@@ -285,7 +294,7 @@ 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 struct EntryCitingRecord: 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
Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swift Renamed +20 / -20
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swiftsimilarity index 89%rename from Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swiftrename to Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swiftindex 9fd34c2..286c5f2 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordFacts.swift@@ -1,7 +1,7 @@ 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.+// The value layer under every record table: 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@@ -95,18 +95,18 @@ public enum SourceRef: Sendable, Equatable, Hashable, Codable {  // MARK: - A fact -/// One discrete statement about a character, citing exactly one source and+/// One discrete statement about a record, 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+/// within a record — 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 {+public struct RecordFact: Sendable, Equatable, Hashable, Codable {     /// The editable text of the fact.     public var statement: String     /// The verbatim evidence span, immutable after acceptance (Q74).@@ -126,20 +126,20 @@ public struct CharacterFact: Sendable, Equatable, Hashable, Codable {      /// 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)+    public var identity: RecordFactIdentity {+        RecordFactIdentity(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)+    public func rekeyed(to nameKey: String) -> RecordFact {+        RecordFact(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)+    public func citing(_ source: SourceRef) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: nameKey, source: source)     }      /// The id a display row takes — **the one spelling**, because the work page@@ -156,7 +156,7 @@ public struct CharacterFact: Sendable, Equatable, Hashable, Codable { }  /// The identity triple, as a value that can key a dictionary or a set.-public struct CharacterFactIdentity: Sendable, Equatable, Hashable {+public struct RecordFactIdentity: Sendable, Equatable, Hashable {     public let nameKey: String     public let source: SourceRef     public let quote: String@@ -170,7 +170,7 @@ public struct CharacterFactIdentity: Sendable, Equatable, Hashable {  // MARK: - Name keys -/// The one normalisation pipeline for character name keys (Q41/Q64).+/// The one normalisation pipeline for record 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@@ -182,7 +182,7 @@ public struct CharacterFactIdentity: Sendable, Equatable, Hashable { /// 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 {+public enum RecordNameKey {      /// The article stripped, after folding. Exactly this one: extending the list     /// to "a "/"an " is speculation the prototype corpus does not support.@@ -211,7 +211,7 @@ public enum CharacterNameKey {  // MARK: - Canonical encoding -/// `Character.factsData`'s one encoding (Q75).+/// Every record table's `factsData` 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,@@ -225,11 +225,11 @@ public enum CharacterNameKey { /// 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 {+public enum RecordFactCodec {      /// 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] {+    public static func canonicalOrder(_ facts: [RecordFact]) -> [RecordFact] {         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]@@ -237,16 +237,16 @@ public enum CharacterFactCodec {         }     } -    public static func encode(_ facts: [CharacterFact]) -> Data? {+    public static func encode(_ facts: [RecordFact]) -> 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] {+    public static func decode(_ data: Data?) -> [RecordFact] {         guard let data, !data.isEmpty else { return [] }-        guard let facts = try? JSONDecoder().decode([CharacterFact].self, from: data) else {+        guard let facts = try? JSONDecoder().decode([RecordFact].self, from: data) else {             return []         }         return canonicalOrder(facts)
Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swift Renamed +106 / -107
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swiftsimilarity index 52%rename from Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swiftrename to Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swiftindex 119e2c8..81bb5b3 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordGroups.swift@@ -1,32 +1,32 @@ import Foundation import SwiftData -// Characters in the duplicate/torn machinery (Req 6.4/6.5).+// Records in the duplicate/torn machinery (Req 6.4/6.5), over any `RecordRow`. //-// **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.+// **Record 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 here: distinct-UUID duplicates of one record are the+// reader's to combine, edit or delete, never the app's to auto-resolve.+// Dropping the content relation makes a multi-member record set unconstructible,+// so `.merge` is structurally unreachable rather than merely unused, and the+// only record 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+/// A record'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 touches any of it). One value type for every kind, so both arms of the+/// resolution sheet read the same variants. ///-/// **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 {+/// **Never bare** (design §Data model). A record always carries an authored+/// name — a nameless one 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 RecordAuthoredContent: AuthoredContent {     public var name: String     public var note: String     /// Sorted at construction: aliases are a set, and two rows listing the same@@ -42,12 +42,12 @@ public struct CharacterAuthoredContent: AuthoredContent {         name: String = "",         note: String = "",         aliases: [String] = [],-        facts: [CharacterFact] = []+        facts: [RecordFact] = []     ) {         self.name = name         self.note = note         self.aliases = aliases.sorted()-        factsData = CharacterFactCodec.encode(facts)+        factsData = RecordFactCodec.encode(facts)     }      /// The stored form, re-encoded canonically so two rows written by different@@ -56,10 +56,10 @@ public struct CharacterAuthoredContent: AuthoredContent {         self.name = name         self.note = note         self.aliases = aliases.sorted()-        self.factsData = CharacterFactCodec.canonicalBytes(factsData)+        self.factsData = RecordFactCodec.canonicalBytes(factsData)     } -    public static let bare = CharacterAuthoredContent()+    public static let bare = RecordAuthoredContent()      /// Always false — see the type's doc comment. `bare` still exists because     /// the protocol requires it and `DuplicateMember.authoredContent` falls back@@ -67,7 +67,7 @@ public struct CharacterAuthoredContent: AuthoredContent {     /// unreachable for characters.     public var isBare: Bool { false } -    public var facts: [CharacterFact] { CharacterFactCodec.decode(factsData) }+    public var facts: [RecordFact] { RecordFactCodec.decode(factsData) }      public var orderComponents: [OrderComponent] {         [@@ -82,35 +82,35 @@ public struct CharacterAuthoredContent: AuthoredContent {     } } -public typealias CharacterDuplicateSet = DuplicateSet<CharacterAuthoredContent>+public typealias RecordDuplicateSet = DuplicateSet<RecordAuthoredContent>  // 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 {+/// Every row sharing one record application UUID, as one logical record — the+/// `EntryGroup`/`WorkGroup` shape, with the same rules, over any `RecordRow`.+public struct RecordGroup<Row: RecordRow> {     public let id: UUID-    public let rows: [CharacterRecord]-    public let representative: CharacterRecord+    public let rows: [Row]+    public let representative: Row     /// The row holding the content the group presents (Q41).-    public let carrier: CharacterRecord-    public let variants: [AuthoredVariant<CharacterAuthoredContent>]+    public let carrier: Row+    public let variants: [AuthoredVariant<RecordAuthoredContent>]      public var isSplit: Bool { rows.count > 1 }     public var isTorn: Bool { variants.count > 1 } -    public var authoredContent: CharacterAuthoredContent? {+    public var authoredContent: RecordAuthoredContent? {         isTorn ? nil : (variants.first?.content ?? .bare)     } -    public var state: RecordGroupState<CharacterAuthoredContent> {+    public var state: RecordGroupState<RecordAuthoredContent> {         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 {+    public var presentedContent: RecordAuthoredContent {         authoredContent ?? variants.first?.content ?? .bare     } @@ -118,44 +118,46 @@ public struct CharacterGroup {     public var modifiedAt: Date { rows.map(\.modifiedAt).max() ?? .distantPast } } +/// The character instantiation, kept as a name because the character surfaces+/// read better for it and every existing call site already says it.+public typealias CharacterGroup = RecordGroup<CharacterRecord>+ extension GroupOrdering { -    public static func authoredContent(of character: CharacterRecord) -> CharacterAuthoredContent {-        CharacterAuthoredContent(-            name: character.name,-            note: character.note,-            aliases: character.aliases,-            factsData: character.factsData)+    public static func authoredContent<Row: RecordRow>(of row: Row) -> RecordAuthoredContent {+        RecordAuthoredContent(+            name: row.name,+            note: row.note,+            aliases: row.aliases,+            factsData: row.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] {+    /// A record'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<Row: RecordRow>(_ row: Row) -> [OrderComponent] {         [-            .absentableString(character.work?.id.uuidString.lowercased()),-            .string(character.nameKey),-            .date(character.createdAt),+            .absentableString(row.ownerWorkID?.uuidString.lowercased()),+            .string(row.nameKey),+            .date(row.createdAt),         ]-            + authoredContent(of: character).orderComponents-            + [.date(character.modifiedAt)]+            + authoredContent(of: row).orderComponents+            + [.date(row.modifiedAt)]     } -    public static func representativeCharacter(_ rows: [CharacterRecord]) -> CharacterRecord? {+    public static func representativeRecord<Row: RecordRow>(_ rows: [Row]) -> Row? {         least(rows, key: representativeComponents)     } -    public static func sortedCharacterRows(_ rows: [CharacterRecord]) -> [CharacterRecord] {+    public static func sortedRecordRows<Row: RecordRow>(_ rows: [Row]) -> [Row] {         stableSorted(rows, key: representativeComponents)     } -    static func characterRowPrecedes(_ lhs: CharacterRecord, _ rhs: CharacterRecord) -> Bool {+    static func recordRowPrecedes<Row: RecordRow>(_ lhs: Row, _ rhs: Row) -> Bool {         OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))             == .orderedAscending     } -    static func characterRowsAreInterchangeable(-        _ lhs: CharacterRecord, _ rhs: CharacterRecord-    ) -> Bool {+    static func recordRowsAreInterchangeable<Row: RecordRow>(_ lhs: Row, _ rhs: Row) -> Bool {         OrderComponent.compare(representativeComponents(lhs), representativeComponents(rhs))             == .orderedSame     }@@ -164,35 +166,38 @@ extension GroupOrdering { 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)+    internal static func recordGroup<Row: RecordRow>(+        id: UUID, rows: [Row]+    ) -> RecordGroup<Row>? {+        let sorted = GroupOrdering.sortedRecordRows(rows)         guard let representative = sorted.first else { return nil }-        let contents = sorted.map(GroupOrdering.authoredContent(of:))+        let contents = sorted.map { GroupOrdering.authoredContent(of: $0) }         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(+        return RecordGroup(             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 recordGroups<Row: RecordRow>(+        _ rows: [Row]+    ) -> [UUID: RecordGroup<Row>] {+        var buckets: [UUID: [Row]] = [:]+        for row in rows { buckets[row.recordID, default: []].append(row) }+        return buckets.compactMapValues { rows in recordGroup(id: rows[0].recordID, rows: rows) }     } -    internal static func characterRows(-        ids: [UUID], context: ModelContext-    ) throws -> [UUID: [CharacterRecord]] {+    /// Every row of each id group, bucketed — the convergence fan-out's read.+    /// The fetch is the conformance's; nothing here writes a predicate.+    internal static func recordRows<Row: RecordRow>(+        _ type: Row.Type = Row.self, ids: [UUID], context: ModelContext+    ) throws -> [UUID: [Row]] {         guard !ids.isEmpty else { return [:] }-        return Dictionary(-            grouping: try context.fetch(-                FetchDescriptor<CharacterRecord>(predicate: #Predicate { ids.contains($0.id) })),-            by: \.id)+        return Dictionary(grouping: try Row.rows(ids: ids, context: context), by: \.recordID)     } } @@ -201,14 +206,14 @@ extension LibraryRepository { /// 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+/// **Every write fans out across the whole record 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 {+public enum CitationRepointing { -    /// Rewrites `characters` and `suppressions` so nothing cites a UUID in+    /// Rewrites `rows` 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@@ -219,20 +224,20 @@ public enum CharacterCitationRepointing {     /// `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-    /// `BackupV11Character` carries as its import value guard, so a backwards-    /// stamp would let an older archive overwrite a newer character.+    /// than the record it rewrites — and `RecordGroup.modifiedAt` is what the+    /// archive record carries as its import value guard, so a backwards stamp+    /// would let an older archive overwrite a newer record.     @discardableResult-    public static func repoint(-        characters: [CharacterRecord],-        suppressions: [CharacterSuppression],+    public static func repoint<Row: RecordRow, Suppression: SuppressionRow>(+        rows: [Row],+        suppressions: [Suppression],         survivors: [UUID: UUID],         timestamp: Date     ) -> Int {         guard !survivors.isEmpty else { return 0 }         var changed = 0 -        for (_, group) in LibraryRepository.characterGroups(characters) {+        for (_, group) in LibraryRepository.recordGroups(rows) {             // 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@@ -249,7 +254,7 @@ public enum CharacterCitationRepointing {             }             let facts = group.presentedContent.facts             guard let rewritten = repointed(facts, survivors: survivors) else { continue }-            let bytes = CharacterFactCodec.encode(rewritten)+            let bytes = RecordFactCodec.encode(rewritten)             for row in group.rows {                 row.factsData = bytes                 row.modifiedAt = max(row.modifiedAt, timestamp)@@ -270,40 +275,34 @@ public enum CharacterCitationRepointing {     /// 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.+    /// a collapse touches are the whole search space **for records that have a+    /// work** — which is what keeps this off the whole-table read the+    /// reconciler's budget could not afford. An orphan (a dangling `workID`,+    /// Req 5.5) is not reached by `rows(of:)`, so a citation it holds to a+    /// collapsed source stays dangling. That is the tolerated state Req 5.5+    /// names, not damage: an orphan belongs to no site, validates nothing, and+    /// degrades in display rather than failing.+    /// Duplicate work rows are handed over too; each conformance's `rows(of:)`+    /// deduplicates them.     @discardableResult-    public static func repoint(-        survivors: [UUID: UUID], in works: [Work], timestamp: Date-    ) -> Int {+    public static func repoint<Row: RecordRow, Suppression: SuppressionRow>(+        _ rowType: Row.Type, _ suppressionType: Suppression.Type,+        survivors: [UUID: UUID], in works: [Work], context: ModelContext, timestamp: Date+    ) throws -> 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,+            rows: try Row.rows(of: works, context: context),+            suppressions: try Suppression.rows(of: works, context: context),+            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]? {+        _ facts: [RecordFact], survivors: [UUID: UUID]+    ) -> [RecordFact]? {         var moved = false-        let rewritten = facts.map { fact -> CharacterFact in+        let rewritten = facts.map { fact -> RecordFact in             guard case .entry(let id) = fact.source, let survivor = survivors[id],                   survivor != id             else { return fact }
Packages/AsterismCore/Sources/AsterismCore/RecordKind.swift Added +24 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecordKind.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordKind.swiftnew file mode 100644index 0000000..37f3842--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordKind.swift@@ -0,0 +1,24 @@+import Foundation++/// Which reader-owned named record a value is about (design §Architecture).+///+/// One enum for the store's generics, the extraction pipeline and the views, so+/// nothing names a kind with a string literal. It is deliberately *not* the+/// enum the duplicate machinery scans with (`DuplicateRecordType`) nor the one a+/// collapse redirect remembers (`CollapsedRecordType`): those two are about rows+/// of every table, and each layer speaks the one that is its own.+public enum RecordKind: String, Sendable, Equatable, CaseIterable, Codable {+    case character+    case place++    /// The other kind — what a conversion goes to, and what a cross-kind hint+    /// points at. Here rather than in a view helper because it is a fact about+    /// the enum, not a piece of copy, and both the store's edit session and the+    /// review model ask it.+    public var other: RecordKind {+        switch self {+        case .character: .place+        case .place: .character+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift Renamed +30 / -27
diff --git a/Packages/AsterismCore/Sources/AsterismCore/CharacterRanking.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swiftsimilarity index 87%rename from Packages/AsterismCore/Sources/AsterismCore/CharacterRanking.swiftrename to Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swiftindex cce5d38..fe2f6af 100644--- a/Packages/AsterismCore/Sources/AsterismCore/CharacterRanking.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordRanking.swift@@ -1,11 +1,12 @@ import Foundation -// The prominence order a work's characters are listed in (Reqs 1 and 2).+// The prominence order a work's records are listed in (Reqs 1 and 2), for any+// `RecordRow`. // // Two pure types, derived on read from data the surfaces already hold: nothing // is stored, no fetch is added, and both the work page and the share sheet rank // the same way against the whole work (Decision 1). Internal rather than public-// because `CharacterGroup` holds SwiftData rows and is not `Sendable` (Q36);+// because `RecordGroup` holds SwiftData rows and is not `Sendable` (Q36); // the tests reach them through `@testable import`.  /// Distance of every live entry from the end of the story (Req 2).@@ -94,13 +95,13 @@ struct StoryPositionIndex {     } } -/// What a character's facts are worth, and the order that follows (Req 1).+/// What a record's facts are worth, and the order that follows (Req 1). /// /// `Σ w(d) · f(n)` over the character's buckets: `w` decays with distance so a /// character who has stopped appearing drifts down, and `f` is concave so /// appearing in many chapters beats being described at length in one /// (Decision 2).-enum CharacterRanking {+enum RecordRanking {      /// `H`: the distance at which a fact is worth half (Q28). An absolute count     /// of positions, never a share of the work's length (Q10).@@ -155,7 +156,7 @@ enum CharacterRanking {     /// distance, and generic → live → dangling within one distance — because     /// that is what makes two identical profiles produce the same `Double` to     /// the last bit (Decision 3).-    static func score(facts: [CharacterFact], index: StoryPositionIndex) -> Double {+    static func score(facts: [RecordFact], index: StoryPositionIndex) -> Double {         var generic = 0         var dangling = 0         var live: [Int: Int] = [:]@@ -192,16 +193,16 @@ enum CharacterRanking {         return total     } -    /// A character group and the facts decoded out of its stored blob — what-    /// the ranking takes and what it hands back (Decision 4).+    /// A record group and the facts decoded out of its stored blob — what the+    /// ranking takes and what it hands back (Decision 4).     ///-    /// `CharacterAuthoredContent.facts` decodes on every access, so a caller+    /// `RecordAuthoredContent.facts` decodes on every access, so a caller     /// that ranked and then read `presentedContent.facts` again would decode     /// the whole cast twice on one work-page open. Carrying the facts out with     /// the group makes the decode the ranking already pays the only one.-    struct RankedCharacter {-        let group: CharacterGroup-        let facts: [CharacterFact]+    struct RankedRecord<Row: RecordRow> {+        let group: RecordGroup<Row>+        let facts: [RecordFact]     }      /// The one decode: every group's stored fact blob read out, before anything@@ -209,8 +210,10 @@ enum CharacterRanking {     ///     /// Split from `order` so Req 4.3's measurement can time the arithmetic it     /// budgets rather than the `Codable` pass around it (Decision 4).-    static func decode(_ groups: [UUID: CharacterGroup]) -> [RankedCharacter] {-        groups.values.map { RankedCharacter(group: $0, facts: $0.presentedContent.facts) }+    static func decode<Row: RecordRow>(+        _ groups: [UUID: RecordGroup<Row>]+    ) -> [RankedRecord<Row>] {+        groups.values.map { RankedRecord(group: $0, facts: $0.presentedContent.facts) }     }      /// A character reduced to what the order needs, derived once per character@@ -227,12 +230,12 @@ enum CharacterRanking {         let id: UUID     } -    private static func sortKey(-        of group: CharacterGroup, facts: [CharacterFact], index: StoryPositionIndex+    private static func sortKey<Row: RecordRow>(+        of group: RecordGroup<Row>, facts: [RecordFact], index: StoryPositionIndex     ) -> SortKey {         SortKey(             score: facts.isEmpty ? 0 : score(facts: facts, index: index),-            nameKey: CharacterNameKey.normalize(group.presentedContent.name),+            nameKey: RecordNameKey.normalize(group.presentedContent.name),             hasFacts: !facts.isEmpty,             id: group.id)     }@@ -254,18 +257,18 @@ enum CharacterRanking {      /// The order (Reqs 1.1, 1.6) over characters whose facts a caller already     /// holds — the seam Req 4.3's measurement times (Decision 4).-    static func order(-        _ characters: [RankedCharacter], index: StoryPositionIndex-    ) -> [RankedCharacter] {-        let keys = characters.map { sortKey(of: $0.group, facts: $0.facts, index: index) }-        return zip(characters, keys).sorted { precedes($0.1, $1.1) }.map(\.0)+    static func order<Row: RecordRow>(+        _ records: [RankedRecord<Row>], index: StoryPositionIndex+    ) -> [RankedRecord<Row>] {+        let keys = records.map { sortKey(of: $0.group, facts: $0.facts, index: index) }+        return zip(records, keys).sorted { precedes($0.1, $1.1) }.map(\.0)     }      /// Decode once, then order (Reqs 1.1, 1.6) — what the work page calls, which     /// draws the facts it carries out (Decision 4).-    static func rank(-        _ groups: [UUID: CharacterGroup], index: StoryPositionIndex-    ) -> [RankedCharacter] {+    static func rank<Row: RecordRow>(+        _ groups: [UUID: RecordGroup<Row>], index: StoryPositionIndex+    ) -> [RankedRecord<Row>] {         order(decode(groups), index: index)     } @@ -276,9 +279,9 @@ enum CharacterRanking {     /// group's are read, so the extension never holds the whole cast's facts at     /// once. The scoring and the ordering are `rank`'s, so the share sheet and     /// the work page still answer with one order (Decision 1).-    static func rankGroups(-        _ groups: [UUID: CharacterGroup], index: StoryPositionIndex-    ) -> [CharacterGroup] {+    static func rankGroups<Row: RecordRow>(+        _ groups: [UUID: RecordGroup<Row>], index: StoryPositionIndex+    ) -> [RecordGroup<Row>] {         groups.values             .map { group in                 (group, sortKey(of: group, facts: group.presentedContent.facts, index: index))
Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift Added +369 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift b/Packages/AsterismCore/Sources/AsterismCore/RecordRow.swiftnew file mode 100644index 0000000..94bd3f5--- /dev/null+++ b/Packages/AsterismCore/Sources/AsterismCore/RecordRow.swift@@ -0,0 +1,369 @@+import Foundation+import SwiftData++// The seam the store's record code is generic over (design §Store generics).+//+// Groups, authored content, repointing, ranking and the read-side presentations+// are mechanical over (name, key, aliases, note, facts, work). Writing them once+// against a protocol and conforming each table is Decision 3's choice over a+// copy that would drift.+//+// **The conformances own every fetch.** A `#Predicate` cannot be written against+// a protocol-typed key path, so generic code writes none: it asks the row type+// for the rows of some works, or for the rows of some record ids, and each table+// answers the way that table must be read — the character tables through their+// inverse, a column-keyed table by predicate.++/// A reader-owned named record with cited facts.+public protocol RecordRow: PersistentModel {++    /// Which kind of record this table holds. Static because the answer is the+    /// table's, never a row's.+    static var kind: RecordKind { get }++    /// The application UUID.+    ///+    /// **`recordID`, not `id`**: `PersistentModel` already vends+    /// `id: PersistentIdentifier`, so a generic `row.id` is ambiguous — proved+    /// by `specs/place-extraction/prototype/generic-store-spike`.+    var recordID: UUID { get }++    var name: String { get set }+    /// The retained key, minted at accept or creation and never re-derived.+    var nameKey: String { get set }+    var aliases: [String] { get set }+    var note: String { get set }+    /// `[RecordFact]` in `RecordFactCodec`'s canonical encoding.+    var factsData: Data? { get set }+    /// The decoded facts. A requirement, not a bare extension member, so a call+    /// through a protocol-typed value dispatches to the one implementation the+    /// extension below provides (Q75).+    var facts: [RecordFact] { get set }+    var createdAt: Date { get set }+    var modifiedAt: Date { get set }++    /// The owning work's UUID as the row states it.+    ///+    /// Orphan-ness is "no work resolves", never `nil`: a row reached by a UUID+    /// column carries a dangling id rather than an absent one.+    var ownerWorkID: UUID? { get }++    /// The rows the given works own, deduplicated.+    ///+    /// `works` is every row of a work's identity group, so a table reached+    /// through an inverse must deduplicate by object identity: one record+    /// reached through two work rows is one record.+    static func rows(of works: [Work], context: ModelContext) throws -> [Self]++    /// The rows carrying the given record ids — every row of each id group.+    ///+    /// The convergence fan-out reads this way rather than by work: ownership+    /// sits outside authored content, so a group whose rows disagree about+    /// their owner is not torn and has to be reachable whole.+    static func rows(ids: [UUID], context: ModelContext) throws -> [Self]++    /// A new row, unattached to any context.+    static func make(+        id: UUID, name: String, nameKey: String, aliases: [String], note: String,+        facts: [RecordFact], timestamp: Date, work: Work?+    ) -> Self++    /// The archive record this table round-trips through.+    associatedtype ArchiveRecord++    /// Identity and content from an archived record; ownership is applied+    /// separately, by `attach(to:archivedWorkID:)`.+    static func make(imported: ArchiveRecord) -> Self++    /// Applies ownership on the way in from an archive.+    ///+    /// A table holding a relationship sets it and leaves an existing one alone+    /// when `work` is nil; a table holding a UUID column writes+    /// `archivedWorkID` where doing so cannot move the row off an owner this+    /// library holds, so an owner the archive names but the library cannot+    /// resolve still round-trips onto a row that has none.+    ///+    /// `workTargets` is what "resolves" means here — the same map the caller+    /// resolved `work` through, keyed by every Work UUID the library holds. A+    /// caller with no map to hand (every path that is not the archive **update**+    /// path) uses the two-argument spelling below.+    func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work])+}++extension RecordRow {++    /// Ownership with no resolution map: an empty one answers "nothing+    /// resolves", which is what a row this call has just built states about the+    /// owner it does not have yet.+    public func attach(to work: Work?, archivedWorkID: UUID?) {+        attach(to: work, archivedWorkID: archivedWorkID, workTargets: [:])+    }++    /// 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 of `character-extraction`).+    ///+    /// Conformances must not redeclare it: one canonical encoding, held in one+    /// place (Q75).+    public var facts: [RecordFact] {+        get { RecordFactCodec.decode(factsData) }+        set { factsData = RecordFactCodec.encode(newValue) }+    }+}++/// A remembered decision not to re-propose something, for one kind of record.+///+/// A **system record**: it never tears, never blocks an export, and converges to+/// the reader's most recent action. The columns are raw on purpose — the enums+/// (`CharacterSuppressionKind`, `CharacterSuppressionStatus`) are shared by every+/// kind and keep their frozen spellings.+public protocol SuppressionRow: PersistentModel {+    static var kind: RecordKind { get }+    var recordID: UUID { get }+    var kindRaw: String { get set }+    var nameKey: String { get set }+    var sourceKindRaw: String? { get set }+    var sourceEntryID: UUID? { get set }+    var evidence: String? { get set }+    var statusRaw: String { get set }+    var actionAt: Date { get set }+    var ownerWorkID: UUID? { get }++    static func rows(of works: [Work], context: ModelContext) throws -> [Self]++    static func make(+        id: UUID, work: Work?, kind: CharacterSuppressionKind, nameKey: String,+        source: SourceRef?, evidence: String?, status: CharacterSuppressionStatus,+        actionAt: Date+    ) -> Self++    associatedtype ArchiveRecord+    static func make(imported: ArchiveRecord) -> Self++    /// Applies ownership on the way in from an archive, on `RecordRow.attach`'s+    /// terms and for the same reason (Q76): a suppression's owner carries no+    /// timestamp either, so `actionAt` cannot see an ownership move, and an+    /// archived row naming a work this library cannot resolve must not take a+    /// local suppression off the work it is on — that would re-propose a name+    /// the reader refused.+    func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work])+}++extension SuppressionRow {++    /// Ownership with no resolution map, for every caller that is not the+    /// archive **update** path — `RecordRow`'s spelling, for its reason.+    public func attach(to work: Work?, archivedWorkID: UUID?) {+        attach(to: work, archivedWorkID: archivedWorkID, workTargets: [:])+    }+}++// MARK: - Character++extension CharacterRecord: RecordRow {++    public static var kind: RecordKind { .character }++    public var recordID: UUID { id }++    /// The relationship's target. Nil is the tolerated in-flight state Req 6.7+    /// names — a character that synced ahead of its work.+    public var ownerWorkID: UUID? { work?.id }++    /// Through the inverse, not by predicate: every row of a split work group+    /// carries its own `characters` array, so the union over the group's rows is+    /// the work's characters, deduplicated by object identity. It replaces a+    /// whole-table fetch filtered to one work, which scaled with the library+    /// rather than with the work.+    public static func rows(of works: [Work], context: ModelContext) throws -> [CharacterRecord] {+        LibraryRepository.characterRows(of: works)+    }++    public static func rows(ids: [UUID], context: ModelContext) throws -> [CharacterRecord] {+        guard !ids.isEmpty else { return [] }+        return try context.fetch(+            FetchDescriptor<CharacterRecord>(predicate: #Predicate { ids.contains($0.id) }))+    }++    public static func make(+        id: UUID, name: String, nameKey: String, aliases: [String], note: String,+        facts: [RecordFact], timestamp: Date, work: Work?+    ) -> CharacterRecord {+        CharacterRecord(+            id: id, name: name, nameKey: nameKey, aliases: aliases, note: note, facts: facts,+            timestamp: timestamp, work: work)+    }++    public static func make(imported record: BackupV12Character) -> CharacterRecord {+        ArchiveRecordBuilders.makeCharacter(record)+    }++    /// A nil target leaves the relationship alone rather than detaching: on the+    /// import path it means "the archive's work is not in this library", which+    /// is no reason to orphan a row that already has an owner. The archived id+    /// and the resolution map say nothing this table can use — an unresolved id+    /// is not a relationship.+    public func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work]) {+        guard let work else { return }+        self.work = work+    }+}++extension CharacterSuppression: SuppressionRow {++    public static var kind: RecordKind { .character }++    public var recordID: UUID { id }++    public var ownerWorkID: UUID? { work?.id }++    public static func rows(+        of works: [Work], context: ModelContext+    ) throws -> [CharacterSuppression] {+        LibraryRepository.characterSuppressionRows(of: works)+    }++    public static func make(+        id: UUID, work: Work?, kind: CharacterSuppressionKind, nameKey: String,+        source: SourceRef?, evidence: String?, status: CharacterSuppressionStatus,+        actionAt: Date+    ) -> CharacterSuppression {+        CharacterSuppression(+            id: id, work: work, kind: kind, nameKey: nameKey, source: source,+            evidence: evidence, status: status, actionAt: actionAt)+    }++    public static func make(imported record: BackupV12Suppression) -> CharacterSuppression {+        ArchiveRecordBuilders.makeSuppression(record)+    }++    /// A relationship, so `CharacterRecord.attach`'s answer: a nil target leaves+    /// what is there alone rather than orphaning it, and the archived id and the+    /// resolution map say nothing this table can use.+    public func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work]) {+        guard let work else { return }+        self.work = work+    }+}++// MARK: - Place++extension Place: RecordRow {++    public static var kind: RecordKind { .place }++    public var recordID: UUID { id }++    /// The column, as the row states it. Never nil: a place reached by a UUID+    /// column carries a dangling id rather than an absent one, which is the+    /// tolerated orphan of Req 5.5 rather than a second kind of absence.+    public var ownerWorkID: UUID? { workID }++    /// By predicate over the identifiers, not through an inverse: `Place`+    /// declares no relationship at all (Decision 3, CLAUDE.md's standing rule).+    /// One fetch answers every work handed in, so the sweep's candidate read and+    /// the work-detail read each make exactly one place fetch and group in+    /// memory (`swiftdata-relationships.md` rule 2).+    public static func rows(of works: [Work], context: ModelContext) throws -> [Place] {+        let ids = Array(Set(works.map(\.id)))+        guard !ids.isEmpty else { return [] }+        return try context.fetch(+            FetchDescriptor<Place>(predicate: #Predicate { ids.contains($0.workID) }))+    }++    public static func rows(ids: [UUID], context: ModelContext) throws -> [Place] {+        guard !ids.isEmpty else { return [] }+        return try context.fetch(+            FetchDescriptor<Place>(predicate: #Predicate { ids.contains($0.id) }))+    }++    /// A nil `work` leaves the column at a fresh UUID, which resolves to no work+    /// and is therefore the orphan state — the only thing a row with no owner to+    /// name can be.+    public static func make(+        id: UUID, name: String, nameKey: String, aliases: [String], note: String,+        facts: [RecordFact], timestamp: Date, work: Work?+    ) -> Place {+        Place(+            id: id, name: name, nameKey: nameKey, aliases: aliases, note: note, facts: facts,+            timestamp: timestamp, workID: work?.id ?? UUID())+    }++    public static func make(imported record: BackupV12Place) -> Place {+        ArchiveRecordBuilders.makePlace(record)+    }++    /// The archive's `workID` is written when it **resolves**, and when the row+    /// has no owner of its own to lose — so an owner the archive names but this+    /// library cannot find still round-trips onto a fresh or orphaned row, and+    /// an archived id naming nothing never takes a place off the work this+    /// library has it on.+    ///+    /// That second clause is the narrowing, and ownership's missing timestamp is+    /// why it has to be here: `workID` moves without touching `modifiedAt` (the+    /// merge does it too), so the merge body's `modifiedAt >=` guard passes on+    /// an equal stamp and cannot see the difference. Without it, an archive+    /// holding place P on a work this library does not have — the tolerated+    /// orphan of Req 5.5, which the codec admits by design — would move a local+    /// P off its work and make it invisible everywhere.+    public func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work]) {+        if let work {+            workID = work.id+        } else if let archivedWorkID, workTargets[workID] == nil {+            workID = archivedWorkID+        }+    }+}++extension PlaceSuppression: SuppressionRow {++    public static var kind: RecordKind { .place }++    public var recordID: UUID { id }++    public var ownerWorkID: UUID? { workID }++    public static func rows(of works: [Work], context: ModelContext) throws -> [PlaceSuppression] {+        let ids = Array(Set(works.map(\.id)))+        guard !ids.isEmpty else { return [] }+        return try context.fetch(+            FetchDescriptor<PlaceSuppression>(predicate: #Predicate { ids.contains($0.workID) }))+    }++    public static func make(+        id: UUID, work: Work?, kind: CharacterSuppressionKind, nameKey: String,+        source: SourceRef?, evidence: String?, status: CharacterSuppressionStatus,+        actionAt: Date+    ) -> PlaceSuppression {+        PlaceSuppression(+            id: id, workID: work?.id ?? UUID(), kind: kind, nameKey: nameKey, source: source,+            evidence: evidence, status: status, actionAt: actionAt)+    }++    public static func make(imported record: BackupV12PlaceSuppression) -> PlaceSuppression {+        ArchiveRecordBuilders.makePlaceSuppression(record)+    }++    /// `Place.attach`'s rule, over the table that keeps the same shape (Q76):+    /// the archived `workID` is written when it **resolves**, and when the row+    /// has no owner of its own to lose.+    ///+    /// The asymmetry the narrowing removes was real: an archived suppression+    /// naming a work this library cannot find would move a local suppression off+    /// its work, and a suppression displaced from its work suppresses nothing —+    /// so the name the reader refused comes back at the next pass. `actionAt`+    /// cannot protect against it, because ownership moves without touching it.+    ///+    /// The insert path reaches this through the two-argument spelling, whose+    /// empty map answers "nothing resolves" for a row that has no owner yet, so+    /// the orphan round trip is unchanged.+    public func attach(to work: Work?, archivedWorkID: UUID?, workTargets: [UUID: Work]) {+        if let work {+            workID = work.id+        } else if let archivedWorkID, workTargets[workID] == nil {+            workID = archivedWorkID+        }+    }+}
Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swiftindex 981b8ad..3f1f372 100644--- a/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/ShareWorkContext.swift@@ -9,7 +9,7 @@ import SwiftData // Derived here rather than in the extension for the reason sharesheet-polish Q6 // records for every other row of that card: the extension lays text out, it does // not decide what the text says. It also keeps the order one answer — the share-// row and the work page rank the same groups through `CharacterRanking`,+// row and the work page rank the same groups through `RecordRanking`, // against the whole work on both (Decision 1 of `character-ranking`), and the // last note is selected the way the work page orders its chapters. @@ -27,7 +27,7 @@ private let shareWorkContextLogger = Logger( /// spoiler boundary to keep them behind. public struct ShareCharacter: Sendable, Equatable {     public let name: String-    /// In the order `CharacterAuthoredContent` holds them, which is sorted at+    /// In the order `RecordAuthoredContent` holds them, which is sorted at     /// construction — the order the work page shows too.     public let aliases: [String] @@ -224,8 +224,8 @@ extension LibraryRepository {         // character's facts are decoded, scored and dropped before the next         // character's are read rather than the whole cast's being held at once         // (Q43). The order is `rank`'s own.-        let characters = CharacterRanking.rankGroups(-            characterGroups(characterRows(of: works)), index: storyPositions+        let characters = RecordRanking.rankGroups(+            recordGroups(characterRows(of: works)), index: storyPositions         )         .map { group in             let content = group.presentedContent
Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift Modified +1 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swiftindex e780012..a212f1a 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkMergePlanner.swift@@ -242,6 +242,7 @@ public enum WorkMergePlanner {             discardedFields: discarded,             sourceDeleted: true,             movedCharacterCount: basis.movedCharacterCount,+            movedPlaceCount: basis.movedPlaceCount,             // Req 7.3: the target's, as its type and its title are. The commit's             // target loop writes none of them, so the preview and the row agree.             workStatus: target.workStatus,
Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swift Renamed +38 / -30
diff --git a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift b/Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swiftsimilarity index 81%rename from Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swiftrename to Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swiftindex aede203..588b710 100644--- a/Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift+++ b/Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swift@@ -9,8 +9,8 @@ import SwiftData // 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 {+/// One of a record's facts, ready to draw (Req 5.2).+public struct WorkRecordFactRow: 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.@@ -43,13 +43,13 @@ public struct WorkCharacterFactRow: Identifiable, Sendable, Equatable {     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 let fact: RecordFact      public init(         id: String, statement: String, quote: String, source: SourceRef,         citedEntryID: UUID?, citationTitle: String?, citationDate: Date? = nil,         citedChapterKey: ChapterKey? = nil,-        isDangling: Bool, fact: CharacterFact+        isDangling: Bool, fact: RecordFact     ) {         self.citedChapterKey = citedChapterKey         self.id = id@@ -64,9 +64,13 @@ public struct WorkCharacterFactRow: Identifiable, Sendable, Equatable {     } } -/// One character of a work, as every surface that displays one sees it.-public struct WorkCharacterPresentation: Identifiable, Sendable, Equatable {+/// One record of a work — a character or a place — as every surface that+/// displays one sees it.+public struct WorkRecordPresentation: Identifiable, Sendable, Equatable {     public let id: UUID+    /// Which collection this record belongs to. The surfaces are parameterised+    /// by it rather than duplicated per kind.+    public let kind: RecordKind     public let name: String     public let note: String     public let aliases: [String]@@ -76,7 +80,7 @@ public struct WorkCharacterPresentation: Identifiable, Sendable, Equatable {     /// 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]+    public let facts: [WorkRecordFactRow]     /// 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@@ -84,14 +88,16 @@ public struct WorkCharacterPresentation: Identifiable, Sendable, Equatable {     /// off.     public let rowCount: Int     /// What the editor opened on, for the whole-step basis check (Q73).-    public let editBasis: CharacterEditBasis+    public let editBasis: RecordEditBasis      public init(-        id: UUID, name: String, note: String, aliases: [String], nameKey: String,-        facts: [WorkCharacterFactRow], isTorn: Bool, rowCount: Int,-        editBasis: CharacterEditBasis+        id: UUID, kind: RecordKind, name: String, note: String,+        aliases: [String], nameKey: String,+        facts: [WorkRecordFactRow], isTorn: Bool, rowCount: Int,+        editBasis: RecordEditBasis     ) {         self.id = id+        self.kind = kind         self.name = name         self.note = note         self.aliases = aliases@@ -118,18 +124,18 @@ extension LibraryRepository {     ///     /// Ordering the *groups* rather than the presentations is what lets the     /// share sheet share the order — the share row carries no facts, so it has-    /// no `WorkCharacterPresentation` to sort. The sheet reaches it through-    /// `CharacterRanking.rankGroups`, which is this order with the facts+    /// no `WorkRecordPresentation` to sort. The sheet reaches it through+    /// `RecordRanking.rankGroups`, which is this order with the facts     /// dropped rather than carried out (Q43); the two are one comparator.     ///     /// Each group comes back with its facts already decoded (Decision 4): the     /// ranking has to decode the stored blob to score it, so it hands the-    /// result on rather than leaving `characterPresentations` to decode the+    /// result on rather than leaving `recordPresentations` to decode the     /// same 200 blobs a second time.-    internal static func rankedCharacterGroups(-        _ groups: [UUID: CharacterGroup], index: StoryPositionIndex-    ) -> [CharacterRanking.RankedCharacter] {-        CharacterRanking.rank(groups, index: index)+    internal static func rankedRecordGroups<Row: RecordRow>(+        _ groups: [UUID: RecordGroup<Row>], index: StoryPositionIndex+    ) -> [RecordRanking.RankedRecord<Row>] {+        RecordRanking.rank(groups, index: index)     }      /// The work's characters as the page draws them, in prominence order.@@ -146,20 +152,21 @@ extension LibraryRepository {     /// the fact *list's* order (Q88) and takes no part in the ranking: within a     /// character the list is a note history, between characters the order is     /// story prominence (Q19).-    internal static func characterPresentations(-        _ groups: [UUID: CharacterGroup],+    internal static func recordPresentations<Row: RecordRow>(+        _ groups: [UUID: RecordGroup<Row>],         index: StoryPositionIndex,         captureOrder: [UUID: Int],         titles: [UUID: String],         dates: [UUID: Date] = [:],         keys: [UUID: ChapterKey] = [:]-    ) -> [WorkCharacterPresentation] {-        rankedCharacterGroups(groups, index: index)+    ) -> [WorkRecordPresentation] {+        rankedRecordGroups(groups, index: index)             .map { ranked in                 let group = ranked.group                 let content = group.presentedContent-                return WorkCharacterPresentation(+                return WorkRecordPresentation(                     id: group.id,+                    kind: Row.kind,                     name: content.name,                     note: content.note,                     aliases: content.aliases,@@ -173,29 +180,30 @@ extension LibraryRepository {                         keys: keys),                     isTorn: group.isTorn,                     rowCount: group.rows.count,-                    editBasis: CharacterEditBasis(characterID: group.id, content: content))+                    editBasis: RecordEditBasis(+                        kind: Row.kind, recordID: group.id, content: content))             }     }      /// Q88's display order, and the citation each fact resolves to.     internal static func factRows(-        _ facts: [CharacterFact],+        _ facts: [RecordFact],         captureOrder: [UUID: Int],         titles: [UUID: String],         dates: [UUID: Date] = [:],         keys: [UUID: ChapterKey] = [:]-    ) -> [WorkCharacterFactRow] {+    ) -> [WorkRecordFactRow] {         facts-            .map { fact -> WorkCharacterFactRow in+            .map { fact -> WorkRecordFactRow in                 switch fact.source {                 case .genericNotes:-                    return WorkCharacterFactRow(+                    return WorkRecordFactRow(                         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(+                    return WorkRecordFactRow(                         id: rowID(fact), statement: fact.statement, quote: fact.quote,                         source: fact.source,                         citedEntryID: live ? entryID : nil,@@ -214,5 +222,5 @@ extension LibraryRepository {             }     } -    private static func rowID(_ fact: CharacterFact) -> String { fact.displayRowID }+    private static func rowID(_ fact: RecordFact) -> String { fact.displayRowID } }
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift Modified +224 / -70
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swiftindex e404c35..b84d580 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift@@ -3,17 +3,23 @@ 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:+/// Four steps, in this order, and the order is the design (§Pipeline): ///-/// 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.+/// 1. **Resolve and filter, per source response and per kind** — each grounded+///    candidate is matched against that kind's existing records 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), its facts 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 — and the copy kept or+///    dropped by Q36's survival predicate.+/// 2. **The dual-kind rule** (Req 1.5) over the survivors of *one response* that+///    share a name key under both kinds. Never across responses (Req 1.4).+/// 3. **Group by (kind, name key)** across the pass's sources — one candidate or+///    bundle per key per kind per pass (Q83) — unioning the kinds the pass+///    returned the key under. Per-source rows would show the same record once+///    per note.+/// 4. **Sort and cap, per kind** (Q15). /// /// Pure and deterministic: the same input assembles the same list, which is /// what lets the reader-visible behaviour not depend on the model repeating@@ -25,65 +31,87 @@ public enum CharacterExtractionAssembler {         context: CharacterExtractionContext,         pass: ExtractionPassKind     ) -> [ExtractionProposal] {-        var order: [String] = []-        var grouped: [String: [GroundedCandidate]] = [:]+        let filtered = survivors(of: candidates, context: context, pass: pass)+        let resolved = applyDualKindRule(to: filtered, context: context, pass: pass)+        return capped(grouped(resolved, revisions: revisions))+    }++    // MARK: - Step 1: one source response, one kind, one name key++    /// One name key of one kind, as one source's response left it.+    private struct Survivor {+        var key: ProposalKey+        var name: String+        var source: SourceRef+        var match: ExistingCharacter?+        /// The key this row's facts are filed under: the target's retained key+        /// where it routes onto one, its own otherwise (Q79).+        var resolvedKey: String+        var facts: [GroundedFact]+        var aliases: [String]+        var returnedKinds: Set<RecordKind>+    }++    private struct ResponseKey: Hashable {+        var source: SourceRef+        var kind: RecordKind+        var nameKey: String+    }++    private static func survivors(+        of candidates: [GroundedCandidate], context: CharacterExtractionContext,+        pass: ExtractionPassKind+    ) -> [Survivor] {+        var order: [ResponseKey] = []+        var grouped: [ResponseKey: [GroundedCandidate]] = [:]         for candidate in candidates {-            if grouped[candidate.nameKey] == nil { order.append(candidate.nameKey) }-            grouped[candidate.nameKey, default: []].append(candidate)+            let key = ResponseKey(source: candidate.source, kind: candidate.kind,+                                  nameKey: candidate.nameKey)+            if grouped[key] == nil { order.append(key) }+            grouped[key, 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)+        return order.compactMap { key in+            guard let copies = grouped[key] else { return nil }+            return survivor(of: copies, key: key, context: context, pass: pass)         }-        // 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],+    private static func survivor(+        of copies: [GroundedCandidate], key: ResponseKey,         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) {+    ) -> Survivor? {+        let match = resolve(key.nameKey, among: context.records(of: key.kind))++        // A name-key suppression blocks a *new candidate* only, and only under+        // its own kind (Q13). A record 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(of: key.kind).contains(key.nameKey) {             return nil         } -        // Q79: everything below is keyed to the character the proposal will-        // actually be written to.-        let resolvedKey = match?.retainedKey ?? key+        let resolvedKey = match?.retainedKey ?? key.nameKey         var facts: [GroundedFact] = []-        var seen: Set<CharacterFactIdentity> = []-        for candidate in group {-            for fact in candidate.facts {+        var seen: Set<RecordFactIdentity> = []+        for copy in copies {+            for fact in copy.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 }+                guard !context.acceptedFacts(of: key.kind).contains(keyed.identity) else { continue }+                if pass == .automatic,+                   context.suppressedFacts(of: key.kind).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)+        var aliasKeys: Set<String> = [key.nameKey]+        for copy in copies {+            for alias in copy.proposedAliases {+                let aliasKey = RecordNameKey.normalize(alias)                 guard !knownKeys.contains(aliasKey), aliasKeys.insert(aliasKey).inserted else {                     continue                 }@@ -91,38 +119,164 @@ public enum CharacterExtractionAssembler {             }         } -        // 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.+        // Q36's survival predicate, which Req 1.5's dual-kind rule then runs+        // over. A *new* name is itself new content, so a name-only candidate+        // stands (Q25); a bundle needs a fact or an alias the record does not+        // already have.         if match != nil, facts.isEmpty, aliases.isEmpty { return nil }-        if match == nil, facts.isEmpty, !group.contains(where: { $0.facts.isEmpty }) {+        if match == nil, facts.isEmpty, !copies.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.+            // means it is not a new record 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 Survivor(key: ProposalKey(kind: key.kind, nameKey: key.nameKey),+                        name: copies[0].name, source: key.source, match: match,+                        resolvedKey: resolvedKey, facts: facts, aliases: aliases,+                        returnedKinds: [key.kind])+    }++    // MARK: - Step 2: the dual-kind rule (Req 1.5)++    /// One response returning a name under both kinds, after both copies have+    /// been filtered under their own kind.+    ///+    /// - both unmatched: one row, displayed as a character (Q20), carrying the+    ///   union of the facts re-deduped under the character kind, and marked as+    ///   returned under both — so skipping it suppresses both (Q23).+    /// - exactly one matched: a bundle for that record and a candidate of the+    ///   other kind, both standing on their own (Q18) and neither marked dual,+    ///   because a skip must never suppress a name an existing record answers to+    ///   (Q33).+    /// - both matched: one bundle per kind (Q22).+    ///+    /// A key whose other copy was filtered out never gets here, and the+    /// survivor is a plain single-kind row (Q30).+    private static func applyDualKindRule(+        to survivors: [Survivor], context: CharacterExtractionContext, pass: ExtractionPassKind+    ) -> [Survivor] {+        struct Pairing: Hashable {+            var source: SourceRef+            var nameKey: String         } -        return ExtractionProposal(-            name: group[0].name, nameKey: key, proposedAliases: aliases, target: target,-            facts: facts, citedRevisions: cited)+        var indexes: [Pairing: [RecordKind: Int]] = [:]+        for (index, survivor) in survivors.enumerated() {+            indexes[Pairing(source: survivor.source, nameKey: survivor.key.nameKey),+                    default: [:]][survivor.key.kind] = index+        }++        var result = survivors+        var folded: Set<Int> = []+        for (_, pair) in indexes {+            guard let character = pair[.character], let place = pair[.place],+                  result[character].match == nil, result[place].match == nil+            else { continue }++            var facts = result[character].facts+            var seen = Set(facts.map(\.identity))+            for fact in result[place].facts {+                let keyed = fact.keyed(to: result[character].resolvedKey)+                guard seen.insert(keyed.identity).inserted else { continue }+                guard !context.acceptedFacts(of: .character).contains(keyed.identity) else {+                    continue+                }+                if pass == .automatic,+                   context.suppressedFacts(of: .character).contains(keyed.identity) { continue }+                facts.append(keyed)+            }+            result[character].facts = facts+            result[character].returnedKinds = [.character, .place]+            folded.insert(place)+        }+        return result.enumerated().filter { !folded.contains($0.offset) }.map(\.element)+    }++    // MARK: - Step 3: one row per kind and name key across the pass++    private static func grouped(+        _ survivors: [Survivor], revisions: [SourceRef: String]+    ) -> [ExtractionProposal] {+        var order: [ProposalKey] = []+        var groups: [ProposalKey: [Survivor]] = [:]+        for survivor in survivors {+            if groups[survivor.key] == nil { order.append(survivor.key) }+            groups[survivor.key, default: []].append(survivor)+        }++        return order.compactMap { key in+            guard let group = groups[key] else { return nil }++            var facts: [GroundedFact] = []+            var seenFacts: Set<RecordFactIdentity> = []+            for survivor in group {+                for fact in survivor.facts where seenFacts.insert(fact.identity).inserted {+                    facts.append(fact)+                }+            }+            facts.sort(by: isOrderedBefore)++            var aliases: [String] = []+            var aliasKeys: Set<String> = [key.nameKey]+            for survivor in group {+                for alias in survivor.aliases+                where aliasKeys.insert(RecordNameKey.normalize(alias)).inserted {+                    aliases.append(alias)+                }+            }++            var cited: [SourceRef: String] = [:]+            var returnedKinds: Set<RecordKind> = []+            for survivor in group {+                cited[survivor.source] = revisions[survivor.source]+                returnedKinds.formUnion(survivor.returnedKinds)+            }++            let target: ExtractionProposal.Target =+                group[0].match.map { .existing($0.id) } ?? .newRecord+            return ExtractionProposal(+                name: group[0].name, nameKey: key.nameKey, kind: key.kind,+                proposedAliases: aliases, target: target, facts: facts, citedRevisions: cited,+                returnedKinds: returnedKinds)+        }+    }++    // MARK: - Step 4: order and caps (Req 1.3, 1.10, Q15)++    /// Ordered by kind then key, then capped per kind: the list a reader sees is+    /// the same list on any device, bounded whatever the model returned, and a+    /// place-heavy note cannot spend the character cap.+    private static func capped(_ proposals: [ExtractionProposal]) -> [ExtractionProposal] {+        var kept: [RecordKind: Int] = [:]+        return proposals+            .sorted { ($0.kind.rawValue, $0.nameKey) < ($1.kind.rawValue, $1.nameKey) }+            .filter { proposal in+                let count = kept[proposal.kind, default: 0]+                guard count < cap(for: proposal.kind) else { return false }+                kept[proposal.kind] = count + 1+                return true+            }+    }++    static func cap(for kind: RecordKind) -> Int {+        switch kind {+        case .character: CharacterExtractionBounds.maximumCandidates+        case .place: CharacterExtractionBounds.maximumPlaceCandidates+        }     }      // 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))+    /// The pipeline's spelling of the store's one matching rule, run over one+    /// kind's records. The tiers themselves live in `RecordMatching`+    /// (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 records: [ExistingCharacter]) -> ExistingCharacter? {+        guard let target = RecordMatching.match(+            nameKey: key, among: records.map(\.matchTarget))         else { return nil }-        return characters.first { $0.id == target.id }+        return records.first { $0.id == target.id }     }      /// Q75's **canonical** order: generic notes first, then entries by UUID,
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift Modified +7 / -0
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swiftindex 1aba4e8..dbfc3c9 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift@@ -27,6 +27,13 @@ public enum CharacterExtractionBounds {     /// nor an aggregation of it may hand the reader an unbounded list.     public static let maximumCandidates = 24 +    /// Place candidates kept from one model response, and place proposals shown+    /// from one pass. Its own constant, not a share of `maximumCandidates`+    /// (Q15): a place-heavy note must not crowd the characters out, nor the+    /// reverse. Half the character cap, and the prototype's 56 place candidates+    /// over 125 sources left it there.+    public static let maximumPlaceCandidates = 12+     /// Facts kept per candidate (Req 1.10).     public static let maximumFactsPerCandidate = 12 
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift Modified +47 / -17
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swiftindex 3780f3e..c381cde 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift@@ -3,7 +3,7 @@ import Foundation  // The seam between the store's values and the pipeline's. //-// `SourceRef`, `CharacterFact` and `CharacterFactIdentity` are shared outright —+// `SourceRef`, `RecordFact` and `RecordFactIdentity` 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@@ -23,7 +23,7 @@ extension ExistingCharacter {     /// 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) {+    public init(_ target: MatchTarget) {         self.init(             id: target.id, name: target.currentNameKey, retainedKey: target.retainedKey,             aliases: target.aliasKeys)@@ -32,16 +32,29 @@ extension ExistingCharacter {  extension CharacterExtractionContext {     /// The whole filter input, from the one locked read that produced it (Q78).-    public init(_ candidate: CharacterExtractionCandidate) {+    ///+    /// **Every slot is stated, per kind.** The character-only convenience the+    /// tests use would compile here and hand the assembler an empty place half,+    /// silently filtering every place candidate through no context at all — so+    /// this reads the candidate's dictionaries rather than one kind's.+    public init(_ candidate: ExtractionCandidate) {+        var records: [RecordKind: [ExistingCharacter]] = [:]+        var acceptedFacts: [RecordKind: Set<RecordFactIdentity>] = [:]+        var suppressedNameKeys: [RecordKind: Set<String>] = [:]+        var suppressedFacts: [RecordKind: Set<RecordFactIdentity>] = [:]+        for kind in RecordKind.allCases {+            records[kind] = candidate.records(of: kind).map(ExistingCharacter.init)+            acceptedFacts[kind] = candidate.acceptedFacts(of: kind)+            suppressedNameKeys[kind] = candidate.suppressions(of: kind).candidateKeys+            suppressedFacts[kind] = candidate.suppressions(of: kind).factIdentities+        }         self.init(-            characters: candidate.characters.map(ExistingCharacter.init),-            acceptedFacts: candidate.acceptedFactIdentities,-            suppressedNameKeys: candidate.suppressions.candidateKeys,-            suppressedFacts: candidate.suppressions.factIdentities)+            records: records, acceptedFacts: acceptedFacts,+            suppressedNameKeys: suppressedNameKeys, suppressedFacts: suppressedFacts)     } } -extension CharacterExtractionCandidate {+extension ExtractionCandidate {     /// 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] {@@ -74,8 +87,8 @@ extension CharacterExtractionCandidate { 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)+    public var storedFact: RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: nameKey, source: source)     } } @@ -83,9 +96,9 @@ 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] {+    public var completedSources: [CompletedSource] {         citedRevisions-            .map { CharacterCompletedSource(ref: $0.key, fingerprint: $0.value) }+            .map { CompletedSource(ref: $0.key, fingerprint: $0.value) }             .sorted { $0.ref.orderToken < $1.ref.orderToken }     } @@ -96,19 +109,36 @@ extension ExtractionProposal {     /// 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).+    ///+    /// **Everything routing-related comes from what the row displayed**, not+    /// from what the pass assembled: `displayedKind` is the kind the commit+    /// writes, matches and suppresses under (Q24), and the target is the record+    /// the reclassify preview resolved (Q59). `returnedKinds` is the exception+    /// and is the assembled value, because it is a statement about the model's+    /// answer rather than about the reader's choice — a skip of a Req 1.5 union+    /// row suppresses under both kinds (Req 2.4, Q23), and a single-kind row+    /// the reader reclassified suppresses under the kind it was decided as and+    /// that alone (Q33).+    ///+    /// Called on the **projected** proposal where the row was reclassified: the+    /// projection's facts are already re-keyed and re-deduped under the+    /// destination kind, and building the request from the held row instead+    /// would send a place decision the character kind's fact set (Q59).     public func decisionRequest(         workID: UUID,-        action: CharacterDecisionAction,+        action: DecisionAction,         struckAliases: Set<String> = [],-        untickedFacts: Set<CharacterFactIdentity> = []-    ) -> CharacterDecisionRequest {+        untickedFacts: Set<RecordFactIdentity> = []+    ) -> DecisionRequest {         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(+        return DecisionRequest(             workID: workID,+            kind: displayedKind,+            returnedKinds: returnedKinds,             action: action,-            displayedKeys: [nameKey] + shownAliases.map(CharacterNameKey.normalize),+            displayedKeys: [nameKey] + shownAliases.map(RecordNameKey.normalize),             displayedTargetID: targetID,             proposedName: name,             proposedAliases: shownAliases,
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift Modified +61 / -21
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swiftindex 2581a12..45028f8 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift@@ -89,13 +89,17 @@ 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>+    /// The work's records, per kind. A bundle targeting one that is gone is+    /// discarded, and it is judged against the kind it is displayed under — a+    /// place bundle is never saved by a character of the same id.+    public var recordIDs: [RecordKind: Set<UUID>] -    public init(revisions: [SourceRef: String], characterIDs: Set<UUID>) {+    public init(revisions: [SourceRef: String], recordIDs: [RecordKind: Set<UUID>]) {         self.revisions = revisions-        self.characterIDs = characterIDs+        self.recordIDs = recordIDs     }++    public func recordIDs(of kind: RecordKind) -> Set<UUID> { recordIDs[kind] ?? [] } }  /// Every piece of per-run state this feature keeps, and the rules for moving@@ -251,9 +255,10 @@ public struct CharacterExtractionLedger: Sendable, Equatable {         }     } -    /// 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.+    /// Q100: held rows are keyed by their **proposal key within a work** — the+    /// assembled kind and the name key (Q28) — 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@@ -265,7 +270,7 @@ public struct CharacterExtractionLedger: Sendable, Equatable {     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 {+            guard let index = rows.firstIndex(where: { $0.key == proposal.key }) else {                 rows.append(proposal)                 continue             }@@ -275,7 +280,13 @@ public struct CharacterExtractionLedger: Sendable, Equatable {     }      /// `newer` as it will be shown, carrying `older`'s facts, aliases and cited-    /// revisions.+    /// revisions, and the kinds both were returned under.+    ///+    /// A reader override outranks the newer row's routing: where `older` carries+    /// a reclassified `displayedKind`, the merged row keeps that kind **and the+    /// target the preview resolved under it**, because `newer`'s target was+    /// resolved under the assembled kind and accepting against it would commit a+    /// decision of one kind to a record of the other (Q24, Q59).     private static func merged(         _ older: ExtractionProposal, into newer: ExtractionProposal     ) -> ExtractionProposal {@@ -289,27 +300,31 @@ public struct CharacterExtractionLedger: Sendable, Equatable {         var aliases = newer.proposedAliases         var seenAliases = Set(newer.aliasKeys + [newer.nameKey])         for alias in older.proposedAliases-        where seenAliases.insert(CharacterNameKey.normalize(alias)).inserted {+        where seenAliases.insert(RecordNameKey.normalize(alias)).inserted {             aliases.append(alias)         }          var revisions = older.citedRevisions         for (source, fingerprint) in newer.citedRevisions { revisions[source] = fingerprint } +        let reclassified = older.displayedKind != older.kind         return ExtractionProposal(-            name: newer.name, nameKey: newer.nameKey, proposedAliases: aliases,-            target: newer.target, facts: facts, citedRevisions: revisions)+            name: newer.name, nameKey: newer.nameKey, kind: newer.kind, proposedAliases: aliases,+            target: reclassified ? older.target : newer.target, facts: facts,+            citedRevisions: revisions,+            displayedKind: reclassified ? older.displayedKind : newer.displayedKind,+            returnedKinds: older.returnedKinds.union(newer.returnedKinds))     }      // MARK: - Decisions -    /// Drops the row the reader just decided. One row per name key per work+    /// Drops the row the reader just decided. One row per proposal 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 {+    public mutating func discard(_ key: ProposalKey, for work: UUID) -> Bool {         guard var proposals = held[work],-              let index = proposals.firstIndex(where: { $0.nameKey == nameKey })+              let index = proposals.firstIndex(where: { $0.key == key })         else { return false }         proposals.remove(at: index)         held[work] = proposals.isEmpty ? nil : proposals@@ -320,7 +335,7 @@ public struct CharacterExtractionLedger: Sendable, Equatable {         held[work] = nil     } -    /// Q66/Q110: re-points a held row at the character the commit says it+    /// Q66/Q110: re-points a held row at the record the commit says it     /// actually resolves onto.     ///     /// A `.reRouted` refusal carries the resolution back precisely so the sheet@@ -330,12 +345,33 @@ public struct CharacterExtractionLedger: Sendable, Equatable {     /// for ever.     @discardableResult     public mutating func retarget(-        nameKey: String, for work: UUID, to characterID: UUID?+        _ key: ProposalKey, for work: UUID, to recordID: UUID?+    ) -> Bool {+        guard var proposals = held[work],+              let index = proposals.firstIndex(where: { $0.key == key })+        else { return false }+        proposals[index].target = recordID.map { .existing($0) } ?? .newRecord+        held[work] = proposals+        return true+    }++    /// Req 2.2: the reader changed the row's kind, and the sheet has previewed+    /// what that kind resolves onto (Q24). Both go onto the held row, so the+    /// choice survives a refresh, a later settlement's merge, and the sheet+    /// being closed and reopened within the app run (Q28).+    ///+    /// The row's identity does not move: `key` names the kind the pass+    /// assembled it under, whichever kind it now displays, so the reader's ticks+    /// and strikes stay attached to the row they were made on.+    @discardableResult+    public mutating func reclassify(+        _ key: ProposalKey, for work: UUID, to kind: RecordKind, target: UUID?     ) -> Bool {         guard var proposals = held[work],-              let index = proposals.firstIndex(where: { $0.nameKey == nameKey })+              let index = proposals.firstIndex(where: { $0.key == key })         else { return false }-        proposals[index].target = characterID.map { .existing($0) } ?? .newCharacter+        proposals[index].displayedKind = kind+        proposals[index].target = target.map { .existing($0) } ?? .newRecord         held[work] = proposals         return true     }@@ -345,7 +381,7 @@ public struct CharacterExtractionLedger: Sendable, Equatable {     /// 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+    /// attempt memory go with it. A held proposal goes when the record 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.     ///@@ -385,7 +421,11 @@ public struct CharacterExtractionLedger: Sendable, Equatable {     }      private func survives(_ proposal: ExtractionProposal, in state: WorkExtractionState) -> Bool {-        if case .existing(let id) = proposal.target, !state.characterIDs.contains(id) {+        // Under the kind the row is *displayed* as, because that is the kind its+        // target was resolved under — the assembled kind's records say nothing+        // about a reclassified row's target.+        if case .existing(let id) = proposal.target,+           !state.recordIDs(of: proposal.displayedKind).contains(id) {             return false         }         return proposal.citedRevisions.allSatisfy { source, fingerprint in
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift Modified +113 / -27
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swiftindex 68c1853..caff13b 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift@@ -2,7 +2,7 @@ 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+// identifies one (`RecordFactIdentity`) 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@@ -51,12 +51,17 @@ public struct GroundedFact: Sendable, Hashable {         self.source = source     } -    public var identity: CharacterFactIdentity {-        CharacterFactIdentity(nameKey: nameKey, source: source, quote: quote)+    public var identity: RecordFactIdentity {+        RecordFactIdentity(nameKey: nameKey, source: source, quote: quote)     } -    /// The same fact filed under another character's key.-    func keyed(to key: String) -> GroundedFact {+    /// The same fact filed under another record's key.+    ///+    /// Public because the review sheet re-keys a row's facts when the reader+    /// reclassifies it (Req 2.2): the destination's retained key is a different+    /// key, and a second spelling of this in the app would be a second answer to+    /// what "the same fact" means (`character-extraction` Q79).+    public func keyed(to key: String) -> GroundedFact {         var copy = self         copy.nameKey = key         return copy@@ -68,16 +73,20 @@ public struct GroundedFact: Sendable, Hashable { public struct GroundedCandidate: Sendable, Equatable {     public var name: String     public var nameKey: String+    /// The array of the model's answer this came out of. Everything downstream+    /// — matching, dedup, suppression — is per kind (Q13).+    public var kind: RecordKind     /// 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],+    public init(name: String, nameKey: String, kind: RecordKind, proposedAliases: [String],                 source: SourceRef, facts: [GroundedFact]) {         self.name = name         self.nameKey = nameKey+        self.kind = kind         self.proposedAliases = proposedAliases         self.source = source         self.facts = facts@@ -103,19 +112,19 @@ public struct ExistingCharacter: Sendable, Equatable {      /// 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))+        var keys: Set<String> = [RecordNameKey.normalize(name), retainedKey]+        keys.formUnion(aliases.map(RecordNameKey.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(+    var matchTarget: MatchTarget {+        MatchTarget(             id: id,-            currentNameKey: CharacterNameKey.normalize(name),+            currentNameKey: RecordNameKey.normalize(name),             retainedKey: retainedKey,-            aliasKeys: aliases.map(CharacterNameKey.normalize),+            aliasKeys: aliases.map(RecordNameKey.normalize),             isTorn: false)     } }@@ -123,26 +132,52 @@ public struct ExistingCharacter: Sendable, Equatable { /// 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.+///+/// Every question is asked per record kind (Q13): a place fact is matched+/// against accepted place facts and place suppressions only, and a skipped+/// place "Bay" must never silence a character "Bay". public struct CharacterExtractionContext: Sendable, Equatable {-    public var characters: [ExistingCharacter]+    /// The work's records of each kind, in the terms matching cares about.+    public var records: [RecordKind: [ExistingCharacter]]     /// Identity triples already accepted. No pass re-proposes these — manual     /// included (Req 1.7, Q49).-    public var acceptedFacts: Set<CharacterFactIdentity>+    public var acceptedFacts: [RecordKind: Set<RecordFactIdentity>]     /// Name keys the reader skipped or deleted. Blocks **new candidates only**,-    /// never bundles for an existing character (Q47).-    public var suppressedNameKeys: Set<String>+    /// never bundles for an existing record (Q47).+    public var suppressedNameKeys: [RecordKind: Set<String>]     /// Fact triples the reader unticked or deleted.-    public var suppressedFacts: Set<CharacterFactIdentity>+    public var suppressedFacts: [RecordKind: Set<RecordFactIdentity>] -    public init(characters: [ExistingCharacter] = [],-                acceptedFacts: Set<CharacterFactIdentity> = [],-                suppressedNameKeys: Set<String> = [],-                suppressedFacts: Set<CharacterFactIdentity> = []) {-        self.characters = characters+    /// **Per kind, every slot stated — the only initialiser.**+    ///+    /// A character-only convenience stood here and was deleted (task 17+    /// review): it compiled everywhere the per-kind one does and handed the+    /// assembler an empty place half, so a caller that forgot the places got+    /// silently correct-looking character output and no places at all. The+    /// assembler suite keeps its own test-side convenience.+    public init(records: [RecordKind: [ExistingCharacter]],+                acceptedFacts: [RecordKind: Set<RecordFactIdentity>],+                suppressedNameKeys: [RecordKind: Set<String>],+                suppressedFacts: [RecordKind: Set<RecordFactIdentity>]) {+        self.records = records         self.acceptedFacts = acceptedFacts         self.suppressedNameKeys = suppressedNameKeys         self.suppressedFacts = suppressedFacts     }++    public func records(of kind: RecordKind) -> [ExistingCharacter] { records[kind] ?? [] }++    public func acceptedFacts(of kind: RecordKind) -> Set<RecordFactIdentity> {+        acceptedFacts[kind] ?? []+    }++    public func suppressedNameKeys(of kind: RecordKind) -> Set<String> {+        suppressedNameKeys[kind] ?? []+    }++    public func suppressedFacts(of kind: RecordKind) -> Set<RecordFactIdentity> {+        suppressedFacts[kind] ?? []+    } }  /// Which kind of pass produced a proposal. The difference is suppression: the@@ -153,16 +188,57 @@ public enum ExtractionPassKind: String, Sendable, Hashable, CaseIterable {     case manual } +/// What identifies a held row: the kind the pass **assembled** it under, and its+/// name key within the work.+///+/// The assembled kind, never the displayed one (Q28). A reader's reclassify is a+/// display choice; making it part of the identity would move the row under them+/// and drop the ticks and strikes they had already made.+public struct ProposalKey: Sendable, Hashable {+    public var kind: RecordKind+    public var nameKey: String++    public init(kind: RecordKind, nameKey: String) {+        self.kind = kind+        self.nameKey = nameKey+    }++    /// The key in the one string form the review sheet identifies a row by.+    ///+    /// Spelled here rather than in the sheet because the UI journeys read it+    /// back out of `character-review-keep-<rowID>` and friends: two spellings+    /// of a row's identity are two rows to a test that queries one of them.+    public var rowID: String { "\(kind.rawValue):\(nameKey)" }+}+ /// One row of the review list: a new candidate, or a bundle of additional-/// content for a character the work already has.+/// content for a record the work already has. public struct ExtractionProposal: Sendable, Equatable {     public enum Target: Sendable, Equatable {-        case newCharacter+        case newRecord         case existing(UUID)     }      public var name: String     public var nameKey: String+    /// The kind the pass assembled this row under — its identity, with the name+    /// key (Q28).+    public var kind: RecordKind+    /// The kind the row is shown and decided under. Starts as `kind` and moves+    /// only when the reader reclassifies (Req 2.2); the decision request is+    /// built from it (Q24).+    public var displayedKind: RecordKind+    /// Every kind this pass returned the name under **whose copy survived the+    /// per-kind filter**.+    ///+    /// A copy the filter dropped is not in here (Q30, Q33) — including the one+    /// whose every reported fact deduped away, which is not shown at all and so+    /// cannot join a union (Q68). A name the model returned twice can therefore+    /// arrive as a plain single-kind row.+    ///+    /// Two of them is Req 1.5's union row, and a skip of that suppresses under+    /// both (Req 2.4, Q23).+    public var returnedKinds: Set<RecordKind>     /// Displayed on the row and strikeable before accepting (Q92/Q96).     public var proposedAliases: [String]     public var target: Target@@ -172,20 +248,30 @@ public struct ExtractionProposal: Sendable, Equatable {     /// Q83).     public var citedRevisions: [SourceRef: String] -    public init(name: String, nameKey: String, proposedAliases: [String], target: Target,-                facts: [GroundedFact], citedRevisions: [SourceRef: String]) {+    public init(name: String, nameKey: String, kind: RecordKind, proposedAliases: [String],+                target: Target, facts: [GroundedFact], citedRevisions: [SourceRef: String],+                displayedKind: RecordKind? = nil, returnedKinds: Set<RecordKind>? = nil) {         self.name = name         self.nameKey = nameKey+        self.kind = kind+        self.displayedKind = displayedKind ?? kind+        self.returnedKinds = returnedKinds ?? [kind]         self.proposedAliases = proposedAliases         self.target = target         self.facts = facts         self.citedRevisions = citedRevisions     } +    public var key: ProposalKey { ProposalKey(kind: kind, nameKey: nameKey) }++    /// Req 1.5's union row: one name the response returned under both kinds,+    /// which the reader decides once (Q12) and is told about (Q38).+    public var isDualKind: Bool { returnedKinds.count > 1 }+     /// 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 aliasKeys: [String] { proposedAliases.map(RecordNameKey.normalize) }      public var isBundle: Bool {         if case .existing = target { return true }
Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift Modified +61 / -24
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swiftindex 3725930..66404eb 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift@@ -21,10 +21,14 @@ public struct GroundingDrop: Sendable, Equatable {     }      public var name: String+    /// Which array the dropped value came out of, so the log line says it+    /// (design §Diagnostics) and an overflow is attributable to one cap.+    public var kind: RecordKind     public var reason: Reason -    public init(name: String, reason: Reason) {+    public init(name: String, kind: RecordKind, reason: Reason) {         self.name = name+        self.kind = kind         self.reason = reason     } }@@ -55,27 +59,58 @@ public struct GroundingOutcome: Sendable, Equatable { /// and never letter-insensitive: folding diacritics away would make Renée and /// Renee the same person. public enum CharacterGrounding {+    /// Both arrays, one set of rules (Q42). The place half differs in exactly+    /// two ways: its own candidate cap (Q15), and the kind every survivor and+    /// every drop is tagged with.     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))+        ground(result.characters.map { Reported(name: $0.name, facts: $0.facts) },+               kind: .character, cap: CharacterExtractionBounds.maximumCandidates,+               in: haystack, source: source.source, into: &outcome)+        ground(result.places.map { place in+                   Reported(name: place.name,+                            facts: place.facts.map {+                                ExtractedFact(statement: $0.statement, quote: $0.quote)+                            })+               },+               kind: .place, cap: CharacterExtractionBounds.maximumPlaceCandidates,+               in: haystack, source: source.source, into: &outcome)+        return outcome+    }++    /// One reported name and its facts, whichever array they arrived in. The two+    /// `@Generable` shapes differ only in their `@Guide` strings (Q49), which+    /// are the model's business and not grounding's.+    private struct Reported {+        var name: String+        var facts: [ExtractedFact]+    }++    private static func ground(_ reported: [Reported], kind: RecordKind, cap: Int,+                               in haystack: String, source: SourceRef,+                               into outcome: inout GroundingOutcome) {+        var kept = 0+        for item in reported {+            guard kept < cap else {+                outcome.drops.append(+                    GroundingDrop(name: item.name, kind: kind, reason: .candidateCap))                 continue             }-            guard let candidate = groundName(character.name, in: haystack, drops: &outcome.drops)+            guard let candidate = groundName(item.name, in: haystack, kind: kind,+                                             drops: &outcome.drops)             else { continue } -            let facts = groundFacts(character.facts, nameKey: candidate.key,-                                    displayName: candidate.name, source: source.source,+            let facts = groundFacts(item.facts, nameKey: candidate.key,+                                    displayName: candidate.name, kind: kind, 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))+                name: candidate.name, nameKey: candidate.key, kind: kind,+                proposedAliases: candidate.aliases, source: source, facts: facts))+            kept += 1         }-        return outcome     }      // MARK: - Names and the slash split (Decision 5)@@ -86,21 +121,22 @@ public enum CharacterGrounding {         var aliases: [String]     } -    private static func groundName(_ raw: String, in haystack: String,+    private static func groundName(_ raw: String, in haystack: String, kind: RecordKind,                                    drops: inout [GroundingDrop]) -> GroundedName? {         let name = raw.trimmingCharacters(in: .whitespacesAndNewlines)          // 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 checks.+        // compound then stands or falls on the ordinary checks. Places split the+        // same way (Req 1.7).         if let split = split(name, in: haystack) { return split }          if let reason = dropReason(for: name, in: haystack) {-            drops.append(GroundingDrop(name: name.isEmpty ? raw : name, reason: reason))+            drops.append(GroundingDrop(name: name.isEmpty ? raw : name, kind: kind, reason: reason))             return nil         }-        return GroundedName(name: name, key: CharacterNameKey.normalize(name), aliases: [])+        return GroundedName(name: name, key: RecordNameKey.normalize(name), aliases: [])     }      /// The first rule a name fails, or nil when it grounds. One list, applied@@ -116,7 +152,7 @@ public enum CharacterGrounding {     /// host-side to fold them into.     private static func dropReason(for name: String, in haystack: String) -> GroundingDrop.Reason? {         guard !name.isEmpty else { return .emptyName }-        guard !pronouns.contains(CharacterNameKey.normalize(name)) else { return .pronounName }+        guard !pronouns.contains(RecordNameKey.normalize(name)) else { return .pronounName }         guard name.count <= CharacterExtractionBounds.maximumNameLength else { return .nameTooLong }         switch presence(of: name, in: haystack) {         case .absent: return .nameNotInSource@@ -192,11 +228,11 @@ public enum CharacterGrounding {         guard components.allSatisfy({ dropReason(for: $0, in: haystack) == nil }) else { return nil }          let head = components[0]-        let key = CharacterNameKey.normalize(head)+        let key = RecordNameKey.normalize(head)         var aliases: [String] = []         var seen: Set<String> = [key]         for component in components.dropFirst() {-            let aliasKey = CharacterNameKey.normalize(component)+            let aliasKey = RecordNameKey.normalize(component)             guard seen.insert(aliasKey).inserted else { continue }             aliases.append(component)         }@@ -206,35 +242,36 @@ public enum CharacterGrounding {     // MARK: - Facts      private static func groundFacts(-        _ facts: [ExtractedFact], nameKey: String, displayName: String,+        _ facts: [ExtractedFact], nameKey: String, displayName: String, kind: RecordKind,         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))+                drops.append(GroundingDrop(name: displayName, kind: kind, 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))+                drops.append(GroundingDrop(name: displayName, kind: kind, reason: .emptyQuote))                 continue             }             guard quote.count <= CharacterExtractionBounds.maximumEvidenceLength else {-                drops.append(GroundingDrop(name: displayName, reason: .quoteTooLong))+                drops.append(GroundingDrop(name: displayName, kind: kind, reason: .quoteTooLong))                 continue             }             guard contains(quote, in: haystack) else {-                drops.append(GroundingDrop(name: displayName, reason: .quoteNotVerbatim))+                drops.append(GroundingDrop(name: displayName, kind: kind, reason: .quoteNotVerbatim))                 continue             }             guard !statement.isEmpty else {-                drops.append(GroundingDrop(name: displayName, reason: .emptyStatement))+                drops.append(GroundingDrop(name: displayName, kind: kind, reason: .emptyStatement))                 continue             }             guard statement.count <= CharacterExtractionBounds.maximumStatementLength else {-                drops.append(GroundingDrop(name: displayName, reason: .statementTooLong))+                drops.append(+                    GroundingDrop(name: displayName, kind: kind, reason: .statementTooLong))                 continue             }             kept.append(GroundedFact(nameKey: nameKey, statement: statement,
Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift Modified +43 / -1
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swiftindex 12a9b45..d41d4d4 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift@@ -36,13 +36,55 @@ public struct ExtractedCharacter: Sendable, Equatable {     } } +/// One statement about a place, and the words in the note that support it.+///+/// Its own `@Generable` type rather than `ExtractedFact` re-used, because the+/// `@Guide` strings are part of the prompt the model sees and the prototype+/// measured this shape (Q49).+@Generable+public struct ExtractedPlaceFact: Sendable, Equatable {+    @Guide(description: "One short statement about the place, 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 ExtractedPlace: Sendable, Equatable {+    @Guide(description: "The place's name exactly as the note spells it.")+    public var name: String++    @Guide(description: "Facts the note states about this place. Empty if the note only mentions the name.")+    public var facts: [ExtractedPlaceFact]++    public init(name: String = "", facts: [ExtractedPlaceFact] = []) {+        self.name = name+        self.facts = facts+    }+}++/// One source's whole answer: both kinds, from the one request (Q4).+///+/// A response that cannot be decoded in full fails the source rather than+/// covering it with one array (Q26), which is what makes the two arrays one+/// result type rather than two calls. @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] = []) {+    @Guide(description: "Named places in the story this note mentions. Empty if it names none.")+    public var places: [ExtractedPlace]++    public init(characters: [ExtractedCharacter] = [], places: [ExtractedPlace] = []) {         self.characters = characters+        self.places = places     } } 
Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift Modified +43 / -23
diff --git a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swiftindex 34e69b9..7245849 100644--- a/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift+++ b/Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift@@ -45,30 +45,50 @@ public struct FoundationCharacterExtractionModelClient: CharacterExtractionModel     // MARK: - Prompting      /// The prototype's instructions, tightened by its findings (Q55) and by-    /// the first real runs (Q113, Q114).+    /// the first real runs (Q113, Q114), then widened to both kinds by+    /// `place-extraction`'s own prototype (its Q43, Q62).     ///-    /// The junk tail the prototype 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. The shipped sweep then added two more shapes: pronouns-    /// reported as characters ("He", "his", "they"), whose facts belong to the-    /// named character they stand for, and places or activities ("hotel",-    /// "sex") reported as if they were people. 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.+    /// The junk tail the character prototype 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. The shipped sweep then added two more+    /// shapes: pronouns reported as characters ("He", "his", "they"), whose+    /// facts belong to the named character they stand for, and places or+    /// activities ("hotel", "sex") reported as if they were people. 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.+    ///+    /// The place paragraph and the epithet sentence are the combined request's+    /// only additions, and the not-a-character list deliberately keeps "places"+    /// so the model files them under places rather than under characters. This+    /// is arm B of `specs/place-extraction/prototype/Sources/main.swift`+    /// **verbatim**: Req 1.8's regression measurement and the Req 6.1 timings in+    /// `prototype/prototype-findings.md` are this text's, so a reword is a+    /// re-measurement (Q62).     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.+    You extract named story characters and named places 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.+    Report only characters and places the note itself names. Use no outside \+    knowledge of any story. Never invent a character, a place, 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 ("the hotel", "New York"), objects, activities and events ("sex", \-    "the fight", "dinner"), organisations, and abstractions. If the note names \-    no characters, return an empty list.+    A title or epithet the note uses as a name, written with a capital letter \+    ("The Crowned One", "Black"), counts as a name. These are not characters: \+    the reader, the author, groups and crowds ("everyone", "the crew"), unnamed \+    roles ("the general", "the innkeeper"), places ("the hotel", "New York"), \+    objects, activities and events ("sex", "the fight", "dinner"), \+    organisations, and abstractions. If the note names no characters, return an \+    empty list of characters.++    A place is a location in the story that the note refers to by a proper name, \+    at any scale: a world, a country, a city, a district, a building, a ship, or \+    a named room. These are not places: a location the note only describes \+    ("the hotel", "her apartment", "the school"), a character, an organisation, \+    an object, an event, or the real world outside the story. If the note names \+    no places, return an empty list of places.      A pronoun is never a character. When the note says "he", "she", "they", \     "his", "her", "their" or "it", work out which named character the word \@@ -81,8 +101,8 @@ public struct FoundationCharacterExtractionModelClient: CharacterExtractionModel     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.+    words is a fact you must not report. A character or place 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@@ -93,8 +113,8 @@ public struct FoundationCharacterExtractionModelClient: CharacterExtractionModel          \(source.text) -        List the named story characters this note mentions, with any facts it \-        states about them.+        List the named story characters and the named places this note mentions, \+        with any facts it states about them.         """     } 
Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift Modified +47 / -47
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swiftindex c7510d3..a488d13 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupExportDegradedRefusalTests.swift@@ -34,7 +34,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(Set(payload.sites.map(\.hostname)) == ["present.example", "orphan.example"])         let synthesised = try #require(payload.sites.first { $0.hostname == "orphan.example" })@@ -75,11 +75,11 @@ struct BackupExportDegradedRefusalTests {         #expect(await repository.diagnostics.quarantineMap()["quarantined.example"] != nil)          let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)         let result = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date())) -        let decoded = try BackupV11Codec.decode(try Data(contentsOf: result.fileURL))+        let decoded = try BackupV12Codec.decode(try Data(contentsOf: result.fileURL))         #expect(decoded.payload.entries.count == 1)         #expect(decoded.payload.titlePatterns.count == 2)         // The union demoted one of the two, which is what makes the archive legal@@ -102,7 +102,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.sites.count == 1)         let site = try #require(payload.sites.first)@@ -140,7 +140,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          let pattern = try #require(payload.titlePatterns.first)         #expect(pattern.id == patternID)@@ -169,7 +169,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.entries.count == 1)         #expect(payload.entries.first?.id == shared)@@ -194,7 +194,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .tornGroups(let payload) = error else {             Issue.record("expected .tornGroups, got \(error)")@@ -221,7 +221,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .unrepresentableValue(let record, _, let value) = error else {             Issue.record("expected .unrepresentableValue, got \(error)")@@ -253,7 +253,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .unrepresentableValue(let record, let field, let value) = error else {             Issue.record("expected .unrepresentableValue, got \(error)")@@ -282,7 +282,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .unrepresentableValue(let record, let field, let value) = error else {             Issue.record("expected .unrepresentableValue, got \(error)")@@ -310,7 +310,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          let record = try #require(payload.works.first { $0.id == workID })         #expect(record.workStatus == .hiatus)@@ -338,7 +338,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          let record = try #require(payload.works.first { $0.id == workID })         // The 7/8 record has nowhere to put a legacy type at all (Req 10.3, Q16).@@ -360,7 +360,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .unrepresentableValue(_, _, let value) = error else {             Issue.record("expected .unrepresentableValue, got \(error)")@@ -400,7 +400,7 @@ struct BackupExportDegradedRefusalTests {             memberships: try context.fetch(FetchDescriptor<WorkSiteMembership>()))         #expect(omitted == [staleID]) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.urlRules.map(\.id) == [currentID])         #expect(payload.urlRules.first?.siteHostname == payload.sites.first?.hostname)@@ -431,11 +431,11 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository()         let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)          let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }          guard case .unrepresentableValue(let record, let field, _) = error else {@@ -479,7 +479,7 @@ struct BackupExportDegradedRefusalTests {             entries: try context.fetch(FetchDescriptor<Entry>()))         #expect(omitted == [retiredID]) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.titlePatterns.map(\.id) == [activeID])         #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname)@@ -507,11 +507,11 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository()         let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)          let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }          guard case .unrepresentableValue(let record, let field, _) = error else {@@ -543,11 +543,11 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository()         let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)          let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }          guard case .unrepresentableValue(let record, let field, _) = error else {@@ -591,7 +591,7 @@ struct BackupExportDegradedRefusalTests {         for order in [activeFirst, retiredFirst] {             #expect(order.map(\.id) == [sharedID, sharedID])             let error = try #require(-                throws: BackupV11ExportError.self,+                throws: BackupV12ExportError.self,                 "the partition must refuse an all-unreadable group holding the active row"             ) {                 try LibraryRepository.partitionUnreadableTitlePatterns(order, entries: entries)@@ -605,10 +605,10 @@ struct BackupExportDegradedRefusalTests {         }          let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)         let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }         guard case .unrepresentableValue(let record, _, _) = error else {             Issue.record("expected .unrepresentableValue, got \(error)")@@ -637,7 +637,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .referencesStillArriving = error else {             Issue.record("expected .referencesStillArriving, got \(error)")@@ -678,7 +678,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .referencesStillArriving(let detail) = error else {             Issue.record("expected .referencesStillArriving, got \(error)")@@ -706,7 +706,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .referencesStillArriving(let detail) = error else {             Issue.record("expected .referencesStillArriving, got \(error)")@@ -732,11 +732,11 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository()         let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)          let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }          guard case .referencesStillArriving = error else {@@ -755,7 +755,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let error = try await expectRefusal { _ = try await repository.backupV11Snapshot() }+        let error = try await expectRefusal { _ = try await repository.backupV12Snapshot() }          guard case .referencesStillArriving = error else {             Issue.record("expected .referencesStillArriving, got \(error)")@@ -780,11 +780,11 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository()         let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)          let error = try await expectRefusal {             _ = try await exporter.export(-                metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+                metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date()))         }          guard case .tornGroups = error else {@@ -811,7 +811,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()         let target = try Self.importIntoEmptyStore(payload)          // What reconciliation would settle on: one row per hostname holding the@@ -836,7 +836,7 @@ struct BackupExportDegradedRefusalTests {         }         let repository = try fixture.diagnosedRepository() -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()         let target = try Self.importIntoEmptyStore(payload)          let rows = try target.fetch(FetchDescriptor<Site>())@@ -862,13 +862,13 @@ struct BackupExportDegradedRefusalTests {         #expect(await repository.diagnostics.isEmpty)          let staging = fixture.directory.appending(path: "staging")-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)         let result = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: Date()))+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: Date())) -        let decoded = try BackupV11Codec.decode(try Data(contentsOf: result.fileURL))-        #expect(decoded.backupFormatVersion == 11)-        #expect(decoded.databaseSchemaVersion == 12)+        let decoded = try BackupV12Codec.decode(try Data(contentsOf: result.fileURL))+        #expect(decoded.backupFormatVersion == 12)+        #expect(decoded.databaseSchemaVersion == 13)         #expect(decoded.payload.entries.count == 1)         #expect(decoded.payload.sites.count == 1)         exporter.cleanup(result)@@ -878,12 +878,12 @@ struct BackupExportDegradedRefusalTests {      private func expectRefusal(         _ body: () async throws -> Void-    ) async throws -> BackupV11ExportError {+    ) async throws -> BackupV12ExportError {         do {             try await body()             Issue.record("expected a named refusal, but the export proceeded")             return .snapshotFailed(reason: "no refusal")-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             return error         }     }@@ -891,13 +891,13 @@ struct BackupExportDegradedRefusalTests {     /// The archive's own import path, into a fresh empty store. Both round-trip     /// tests go through the strict reference validator on the way in, which is     /// what makes "the archive is legal" an assertion rather than a hope.-    private static func importIntoEmptyStore(_ payload: BackupV11Payload) throws -> ModelContext {-        let encoded = try BackupV11Codec.encode(+    private static func importIntoEmptyStore(_ payload: BackupV12Payload) throws -> ModelContext {+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))-        let decoded = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DegradedExportFixture.epoch))+        let decoded = try BackupV12Codec.decode(encoded) -        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)         let container = try ModelContainer(for: schema, configurations: [configuration])
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift Modified +158 / -87
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swiftindex f90cbb5..9026588 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift@@ -4,7 +4,8 @@ import Testing  @testable import AsterismCore -/// The byte-for-byte pin on the 11/12 export (T-2316, `work-creators` Req 9.1).+/// The byte-for-byte pin on the 12/13 export (T-2276, `place-extraction`+/// Req 5.1). /// /// Nothing in the other suites would notice a change to a key's spelling, a sort /// order, or a number's formatting: they assert on decoded *values*, and a@@ -13,8 +14,9 @@ import Testing /// So this suite asserts on the bytes. A library populating **every** payload /// array — including a two-site Work, a dismissed pair, an orphan membership, /// both coverage fingerprints, a series with a fractional position, an-/// unresolved membership, an unresolved link, a merged creator, a removed role-/// and two unresolved credits — is built through the real+/// unresolved membership, an unresolved link, a merged creator, a removed role,+/// two unresolved credits, a place, a place suppression citing an entry and a+/// place whose work the archive never carried — is built through the real /// import path, exported /// through the real projection and codec, and compared to a recorded archive /// character for character. A diff here is a change to what a backup file *is*,@@ -24,13 +26,13 @@ import Testing /// is a literal, `M5Fixture` runs on a `FixedRepositoryClock`, and the canonical /// encoder sorts keys while the projection sorts every array by identifier. ///-/// **Recorded at 11/12 for T-2316.** The payload gained a creator array, a role-/// array and a credit array, and the envelope moved with them.+/// **Recorded at 12/13 for T-2276.** The payload gained a place array and a+/// place-suppression array, and the envelope moved with them. /// Re-recording is a deliberate act with a repeatable /// procedure: run this suite's byte test with `ASTERISM_RECORD_GOLDEN=1` set and /// it writes the fixture and fails, then run it again without the flag /// (`rule-citation-by-uuid` Q22).-@Suite("Backup 11/12 golden export", .serialized)+@Suite("Backup 12/13 golden export", .serialized) struct BackupGoldenExportTests {      /// The recorded archive. Regenerating it is a deliberate act — see the@@ -38,10 +40,10 @@ struct BackupGoldenExportTests {     private static var goldenURL: URL {         URL(fileURLWithPath: #filePath)             .deletingLastPathComponent()-            .appending(path: "Fixtures/backup-11-12-golden.json")+            .appending(path: "Fixtures/backup-12-13-golden.json")     } -    /// Every array the 11/12 payload declares is non-empty, so the golden below is+    /// Every array the 12/13 payload declares is non-empty, so the golden below is     /// evidence about the whole projection rather than about the half a smaller     /// fixture would reach.     @Test("The golden library populates every payload array")@@ -63,6 +65,8 @@ struct BackupGoldenExportTests {         #expect(!payload.creators.isEmpty)         #expect(!payload.creatorRoles.isEmpty)         #expect(!payload.credits.isEmpty)+        #expect(!payload.places.isEmpty)+        #expect(!payload.placeSuppressions.isEmpty)          // The shapes the fixture exists to reach, named so a fixture edit that         // quietly drops one fails here rather than only moving the golden bytes.@@ -137,6 +141,15 @@ struct BackupGoldenExportTests {         #expect(payload.characters.contains { $0.workID == nil })         #expect(payload.characters.contains { !$0.facts.isEmpty })         #expect(payload.suppressions.contains { $0.sourceEntryID != nil })+        // `place-extraction` Req 5.1: the two place arrays, with the shapes the+        // fixture exists to reach — a place carrying facts and aliases, a+        // suppression citing an entry, and the Req 5.5 orphan, which a place+        // spells as a `workID` no record answers for rather than as a nil (Q60).+        #expect(payload.places.count == 2)+        #expect(payload.places.contains { !$0.facts.isEmpty && !$0.aliases.isEmpty })+        #expect(payload.places.contains { $0.workID == BackupGoldenLibrary.absentWorkID })+        #expect(payload.places.allSatisfy { !$0.nameKey.isEmpty })+        #expect(payload.placeSuppressions.contains { $0.sourceEntryID != nil })         // The agreeing duplicate rows project to one record each (Req 8.2), and         // their two membership rows fold to the one the reconciler would keep.         #expect(payload.works.count(where: { $0.id == BackupGoldenLibrary.duplicateWorkID }) == 1)@@ -147,10 +160,10 @@ struct BackupGoldenExportTests {                 == 1)     } -    @Test("The 11/12 export of the golden library is byte-identical to the recorded archive")+    @Test("The 12/13 export of the golden library is byte-identical to the recorded archive")     func exportIsByteIdenticalToTheRecordedArchive() async throws {         let payload = try await Self.exportedPayload()-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload, metadata: BackupGoldenLibrary.metadata)          // Q22: every generation bump used to re-record the golden by hand from@@ -172,7 +185,7 @@ struct BackupGoldenExportTests {         #expect(             encoded == golden,             """-            the 11/12 export of the golden library no longer produces the recorded \+            the 12/13 export of the golden library no longer produces the recorded \             bytes. An archive's bytes are its identity — the checksum is taken \             over them — so this is a wire-format change unless it is a bug. \             Establish which before re-recording \(Self.goldenURL.lastPathComponent) \@@ -187,8 +200,8 @@ struct BackupGoldenExportTests {         let golden = try Data(contentsOf: Self.goldenURL)         let plan = try BackupImporter.plan(from: golden) -        #expect(plan.metadata.formatVersion == 11)-        #expect(plan.metadata.schemaVersion == 12)+        #expect(plan.metadata.formatVersion == 12)+        #expect(plan.metadata.schemaVersion == 13)         #expect(plan.counts.entries == plan.metadata.entryCount)         #expect(plan.counts.works == plan.metadata.workCount)     }@@ -214,13 +227,13 @@ struct BackupGoldenExportTests {     /// `recordedArchiveRestoresIntoAV9Library` went away (Q23).     @Test("An export imported into an empty library re-exports the same bytes")     func exportImportExportIsByteIdentical() async throws {-        let first = try BackupV11Codec.encode(+        let first = try BackupV12Codec.encode(             payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata)          let target = try await M5Fixture()         try await target.repository.confirmImport(plan: try BackupImporter.plan(from: first))-        let second = try BackupV11Codec.encode(-            payload: try await target.repository.backupV11Snapshot(),+        let second = try BackupV12Codec.encode(+            payload: try await target.repository.backupV12Snapshot(),             metadata: BackupGoldenLibrary.metadata)          #expect(second == first)@@ -232,7 +245,7 @@ struct BackupGoldenExportTests {     /// detached, and the dismissed pair imports verbatim.     @Test("An orphan membership and a dismissed pair survive the restore")     func orphansSurviveTheRestore() async throws {-        let archive = try BackupV11Codec.encode(+        let archive = try BackupV12Codec.encode(             payload: try await Self.exportedPayload(), metadata: BackupGoldenLibrary.metadata)          let target = try await M5Fixture()@@ -249,17 +262,17 @@ struct BackupGoldenExportTests {      /// Imports the golden archive into a fresh library, seeds the duplicate rows     /// no write path produces, and exports what results.-    private static func exportedPayload() async throws -> BackupV11Payload {+    private static func exportedPayload() async throws -> BackupV12Payload {         let fixture = try await M5Fixture()         let plan = try BackupImporter.plan(-            from: try BackupV11Codec.encode(+            from: try BackupV12Codec.encode(                 payload: BackupGoldenLibrary.payload, metadata: BackupGoldenLibrary.metadata))         try await fixture.repository.confirmImport(plan: plan)         try await fixture.repository.seedM5Rows(             sites: BackupGoldenLibrary.duplicateSites,             works: BackupGoldenLibrary.duplicateWorks,             entries: BackupGoldenLibrary.duplicateEntries)-        return try await fixture.repository.backupV11Snapshot()+        return try await fixture.repository.backupV12Snapshot()     } } @@ -294,7 +307,7 @@ extension LibraryRepository { }  /// The archive the golden library is built from: one record of every kind the-/// 11/12 payload can hold, with literal identifiers and one literal date.+/// 12/13 payload can hold, with literal identifiers and one literal date. enum BackupGoldenLibrary {     static let created = Date(timeIntervalSince1970: 1_000_000) @@ -339,6 +352,16 @@ enum BackupGoldenLibrary {     static let candidateSuppressionID = UUID(uuidString: "5099e5ed-0000-4000-8000-000000000001")!     static let factSuppressionID = UUID(uuidString: "5099e5ed-0000-4000-8000-000000000002")! +    /// `place-extraction` Req 5.1. The place the reader accepted, the place whose+    /// work this archive never carried — Req 5.5's orphan, which the file has to+    /// hold as it stands — and a place suppression citing an entry, which is the+    /// shape that fills every optional column of the record at once.+    static let keepID = UUID(uuidString: "91ace000-0000-4000-8000-000000000001")!+    static let orphanPlaceID = UUID(uuidString: "91ace000-0000-4000-8000-000000000002")!+    static let placeCandidateSuppressionID = UUID(+        uuidString: "5099e5ed-0000-4000-8000-000000000011")!+    static let placeFactSuppressionID = UUID(uuidString: "5099e5ed-0000-4000-8000-000000000012")!+     static let duplicateWorkID = UUID(uuidString: "d0000000-0000-4000-8000-000000000001")!     static let duplicateEntryID = UUID(uuidString: "d0000000-0000-4000-8000-000000000002")! @@ -373,14 +396,14 @@ enum BackupGoldenLibrary {     static let secondSiteWorkURL = "https://plain.example/works/actual-title"     static let articleTitleSuffix = " - Articles Example" -    static var metadata: BackupV11Metadata {-        BackupV11Metadata(appBuild: "golden", exportedAt: created)+    static var metadata: BackupV12Metadata {+        BackupV12Metadata(appBuild: "golden", exportedAt: created)     }      // MARK: The archive -    static var payload: BackupV11Payload {-        BackupV11Payload(+    static var payload: BackupV12Payload {+        BackupV12Payload(             entries: [notedEntry, plainEntry, articleEntry],             works: [typedWork, foldedWork, legacyWork],             sites: [taughtSite, plainSite, articlesSite],@@ -398,6 +421,8 @@ enum BackupGoldenLibrary {             distinctPairs: [distinctPair],             characters: [guide, orphan],             suppressions: [candidateSuppression, factSuppression],+            places: [keep, orphanPlace],+            placeSuppressions: [placeCandidateSuppression, placeFactSuppression],             series: [series],             links: [resolvedLink, unresolvedLink],             creators: creators,@@ -410,7 +435,7 @@ enum BackupGoldenLibrary {     /// Two active creators and the alias one of them absorbed. The alias points     /// at its final survivor, which is the only shape the reference checks     /// accept (Req 9.5).-    private static var creators: [BackupV11Creator] {+    private static var creators: [BackupV12Creator] {         [             creator(id: creatorID, name: "Mori Ayane", notes: "Also draws."),             creator(id: studioID, name: "Studio Lantern"),@@ -424,7 +449,7 @@ enum BackupGoldenLibrary {     /// them — pristine on every field, so an import of this archive into a fresh     /// library matches them by identifier and writes nothing — plus a role the     /// reader added and one they removed.-    private static var creatorRoles: [BackupV11CreatorRole] {+    private static var creatorRoles: [BackupV12CreatorRole] {         CreatorRoleSeeding.seeds.map {             role(                 id: $0.id, name: $0.name, position: $0.position,@@ -439,7 +464,7 @@ enum BackupGoldenLibrary {     /// removed role as well (Q27), the artist credit holding a role identifier     /// no record answers for, and a credit whose work this archive never     /// carried.-    private static var credits: [BackupV11Credit] {+    private static var credits: [BackupV12Credit] {         [             credit(                 id: authorCreditID, workID: typedWorkID, creatorID: creatorID,@@ -456,8 +481,8 @@ enum BackupGoldenLibrary {     private static func creator(         id: UUID, name: String, notes: String = "", state: CreatorState = .active,         canonicalID: UUID? = nil-    ) -> BackupV11Creator {-        BackupV11Creator(+    ) -> BackupV12Creator {+        BackupV12Creator(             id: id, name: name, nameModifiedAt: created, notes: notes,             notesModifiedAt: created, stateRaw: state.rawValue, stateModifiedAt: created,             canonicalID: canonicalID, createdAt: created, modifiedAt: created)@@ -466,8 +491,8 @@ enum BackupGoldenLibrary {     private static func role(         id: UUID, name: String, position: Int, state: CreatorRoleState = .active,         stamp: Date = created-    ) -> BackupV11CreatorRole {-        BackupV11CreatorRole(+    ) -> BackupV12CreatorRole {+        BackupV12CreatorRole(             id: id, name: name, nameModifiedAt: stamp, position: position,             positionModifiedAt: stamp, stateRaw: state.rawValue, stateModifiedAt: stamp,             canonicalID: nil, createdAt: stamp, modifiedAt: stamp)@@ -475,40 +500,40 @@ enum BackupGoldenLibrary {      private static func credit(         id: UUID, workID: UUID, creatorID: UUID, roleIDs: [UUID]-    ) -> BackupV11Credit {-        BackupV11Credit(+    ) -> BackupV12Credit {+        BackupV12Credit(             id: id, workID: workID, creatorID: creatorID,             roleIDs: roleIDs.map(\.uuidString).sorted(),             createdAt: created, modifiedAt: created)     }      /// The one series row, carrying notes so the golden pins that column too.-    private static var series: BackupV11Series {-        BackupV11Series(+    private static var series: BackupV12Series {+        BackupV12Series(             id: seriesID, name: "Ashfall Cycle", notes: "Read 2.5 after 2.",             createdAt: created, modifiedAt: created)     }      /// A link over two Works the archive carries.-    private static var resolvedLink: BackupV11Link {+    private static var resolvedLink: BackupV12Link {         let ids = WorkDistinctPair.sortedIDs(typedWorkID, legacyWorkID)-        return BackupV11Link(+        return BackupV12Link(             id: resolvedLinkID, lowerWorkID: ids.lower, higherWorkID: ids.higher,             linkType: "adaptation", createdAt: created, modifiedAt: created)     }      /// Req 13.5's tolerated half for links: one end has not arrived. It imports     /// verbatim and stays the reader's to remove.-    private static var unresolvedLink: BackupV11Link {+    private static var unresolvedLink: BackupV12Link {         let ids = WorkDistinctPair.sortedIDs(foldedWorkID, absentWorkID)-        return BackupV11Link(+        return BackupV12Link(             id: unresolvedLinkID, lowerWorkID: ids.lower, higherWorkID: ids.higher,             linkType: "spin-off", createdAt: created, modifiedAt: created)     }      /// The whole-title rule names the Work by trimming the boilerplate prefix.-    private static var pattern: BackupV11TitlePattern {-        BackupV11TitlePattern(+    private static var pattern: BackupV12TitlePattern {+        BackupV12TitlePattern(             id: patternID, siteHostname: taughtHost, version: 1, isActive: true,             createdAt: created,             definition: StoredPatternDefinition(@@ -516,8 +541,8 @@ enum BackupGoldenLibrary {     }      /// The articles site's retained history, and the fixture's only `trimSuffix`.-    private static var articlePattern: BackupV11TitlePattern {-        BackupV11TitlePattern(+    private static var articlePattern: BackupV12TitlePattern {+        BackupV12TitlePattern(             id: articlePatternID, siteHostname: articlesHost, version: 1, isActive: false,             createdAt: created,             definition: StoredPatternDefinition(@@ -525,8 +550,8 @@ enum BackupGoldenLibrary {     }      /// A sequence-only query rule extracts "94" from the raw URL.-    private static var rule: BackupV11URLRule {-        BackupV11URLRule(+    private static var rule: BackupV12URLRule {+        BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: created,             origin: .readerTaught,             definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),@@ -535,31 +560,31 @@ enum BackupGoldenLibrary {      /// Carries the `junkSuffixRule` column, which no other site in the fixture     /// sets and which is therefore absent from the file altogether without it.-    private static var taughtSite: BackupV11Site {-        BackupV11Site(+    private static var taughtSite: BackupV12Site {+        BackupV12Site(             hostname: taughtHost, displayName: "Golden", mode: .taught,             junkSuffixRule: try! JunkSuffixRule(                 version: 1, anchors: [try! SegmentPositionSpec(origin: .end, offset: 0)]))     } -    private static var plainSite: BackupV11Site {-        BackupV11Site(+    private static var plainSite: BackupV12Site {+        BackupV12Site(             hostname: plainHost, displayName: "Plain", mode: .untaught, junkSuffixRule: nil)     }      /// The third site mode. `.articles` may hold neither an active title rule     /// nor a current URL rule, so its retained pattern is inactive — which is     /// also where the fixture's `trimSuffix` lives.-    private static var articlesSite: BackupV11Site {-        BackupV11Site(+    private static var articlesSite: BackupV12Site {+        BackupV12Site(             hostname: articlesHost, displayName: "Articles", mode: .articles,             junkSuffixRule: nil)     }      private static func workType(         id: UUID, name: String, state: WorkTypeState = .active, canonicalID: UUID? = nil-    ) -> BackupV11WorkType {-        BackupV11WorkType(+    ) -> BackupV12WorkType {+        BackupV12WorkType(             id: id, name: name, stateRaw: state.rawValue, canonicalID: canonicalID,             createdAt: created, modifiedAt: created)     }@@ -568,8 +593,8 @@ enum BackupGoldenLibrary {      /// The multi-site Work's first site: a rule-derived identity, its cited rule     /// and a confirmed Work URL.-    private static var taughtMembership: BackupV11Membership {-        BackupV11Membership(+    private static var taughtMembership: BackupV12Membership {+        BackupV12Membership(             id: taughtMembershipID, workID: typedWorkID, hostname: taughtHost,             createdAt: created, urlIdentity: workIdentity, urlIdentityState: .rule,             urlIdentityRuleID: ruleID, workURLString: workURL)@@ -578,22 +603,22 @@ enum BackupGoldenLibrary {     /// Its second site (Req 9.1): a different Work URL, no identity, and no     /// Entries at all — a membership that outlives its entries (Req 7.1) is the     /// ordinary shape after a cross-site merge.-    private static var secondSiteMembership: BackupV11Membership {-        BackupV11Membership(+    private static var secondSiteMembership: BackupV12Membership {+        BackupV12Membership(             id: secondSiteMembershipID, workID: typedWorkID, hostname: plainHost,             createdAt: created.addingTimeInterval(1), urlIdentity: nil,             urlIdentityState: .none, urlIdentityRuleID: nil, workURLString: secondSiteWorkURL)     } -    private static var plainMembership: BackupV11Membership {-        BackupV11Membership(+    private static var plainMembership: BackupV12Membership {+        BackupV12Membership(             id: plainMembershipID, workID: foldedWorkID, hostname: plainHost,             createdAt: created, urlIdentity: nil, urlIdentityState: .none,             urlIdentityRuleID: nil, workURLString: nil)     } -    private static var articleMembership: BackupV11Membership {-        BackupV11Membership(+    private static var articleMembership: BackupV12Membership {+        BackupV12Membership(             id: articleMembershipID, workID: legacyWorkID, hostname: articlesHost,             createdAt: created, urlIdentity: nil, urlIdentityState: .none,             urlIdentityRuleID: nil, workURLString: nil)@@ -601,17 +626,17 @@ enum BackupGoldenLibrary {      /// Req 8.3, Q22: a membership whose Work has not arrived. It imports     /// unattached, keeps the Work it names, and is deleted only with that Work.-    private static var orphanMembership: BackupV11Membership {-        BackupV11Membership(+    private static var orphanMembership: BackupV12Membership {+        BackupV12Membership(             id: orphanMembershipID, workID: absentWorkID, hostname: plainHost,             createdAt: created, urlIdentity: "plain.example/absent",             urlIdentityState: .legacyUnverified, urlIdentityRuleID: nil, workURLString: nil)     }      /// Req 5.5: the reader said these two are not the same work.-    private static var distinctPair: BackupV11DistinctPair {+    private static var distinctPair: BackupV12DistinctPair {         let ids = WorkDistinctPair.sortedIDs(typedWorkID, foldedWorkID)-        return BackupV11DistinctPair(+        return BackupV12DistinctPair(             id: distinctPairID, lowerWorkID: ids.lower, higherWorkID: ids.higher,             recordedAt: created)     }@@ -624,8 +649,8 @@ enum BackupGoldenLibrary {     /// It is also the Work carrying all three V10 status fields **off** their     /// defaults (Req 8.1), so the golden pins their spellings rather than only     /// the ones a fresh row would have anyway.-    private static var typedWork: BackupV11Work {-        BackupV11Work(+    private static var typedWork: BackupV12Work {+        BackupV12Work(             id: typedWorkID, displayTitle: workName, lastParsedTitle: workName,             genericNotes: genericNotes, genreTags: ["fantasy"], titleProvenance: .parsed,             workStatus: .hiatus, readingStatus: .abandoned,@@ -640,8 +665,8 @@ enum BackupGoldenLibrary {      /// The work citing the **folded** type row, so the import's canonical chase     /// and the export's directory both have something to resolve.-    private static var foldedWork: BackupV11Work {-        BackupV11Work(+    private static var foldedWork: BackupV12Work {+        BackupV12Work(             id: foldedWorkID, displayTitle: "Plain Work", lastParsedTitle: nil,             genericNotes: "", genreTags: [], titleProvenance: .manual,             workStatus: .finished, readingStatus: .finished, verdict: "",@@ -653,8 +678,8 @@ enum BackupGoldenLibrary {     /// The untyped work. A pre-feature `typeRaw` has not been carried since 7/8     /// (`multi-site-works` Req 10.3, Q16), so this is what such a Work archives     /// as.-    private static var legacyWork: BackupV11Work {-        BackupV11Work(+    private static var legacyWork: BackupV12Work {+        BackupV12Work(             id: legacyWorkID, displayTitle: "An Article", lastParsedTitle: nil,             genericNotes: "", genreTags: [], titleProvenance: .manual,             workStatus: .ongoing, readingStatus: .reading, verdict: "",@@ -669,14 +694,14 @@ enum BackupGoldenLibrary {     // MARK: The entries      /// The v3 key embeds host + resolved Work name + sequence.-    private static var notedEntry: BackupV11Entry {+    private static var notedEntry: BackupV12Entry {         let rawURL = "https://\(taughtHost)/read?chapter=94&x=1"         let key = EntryIdentityKeyV3Codec.encode(             try! URLSequenceNameIdentity(                 hostname: ExactScalarString(taughtHost),                 workName: ExactScalarString(workName),                 chapterSequence: ExactScalarString("94")))-        return BackupV11Entry(+        return BackupV12Entry(             id: notedEntryID, captureTitle: titlePrefix + workName, captureTitleSource: .host,             rawURL: rawURL, canonicalURL: nil, hostname: taughtHost,             entryIdentityKey: key, conservativeIdentityKey: rawURL,@@ -695,9 +720,9 @@ enum BackupGoldenLibrary {      /// The untaught site's Entry: a conservative key, which is what capture     /// writes where no rule has been taught.-    private static var plainEntry: BackupV11Entry {+    private static var plainEntry: BackupV12Entry {         let rawURL = "https://\(plainHost)/read/7"-        return BackupV11Entry(+        return BackupV12Entry(             id: plainEntryID, captureTitle: "Plain Work", captureTitleSource: .manual,             rawURL: rawURL, canonicalURL: nil, hostname: plainHost,             entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,@@ -712,9 +737,9 @@ enum BackupGoldenLibrary {     /// The articles site's Entry, and the fixture's only `canonicalURL`: a     /// capture whose raw URL carried a tracking parameter the canonical form     /// drops.-    private static var articleEntry: BackupV11Entry {+    private static var articleEntry: BackupV12Entry {         let rawURL = "https://\(articlesHost)/posts/hello?utm_source=share"-        return BackupV11Entry(+        return BackupV12Entry(             id: articleEntryID, captureTitle: "An Article" + articleTitleSuffix,             captureTitleSource: .host,             rawURL: rawURL, canonicalURL: "https://\(articlesHost)/posts/hello",@@ -729,12 +754,12 @@ enum BackupGoldenLibrary {      // MARK: The characters -    private static var guide: BackupV11Character {-        BackupV11Character(+    private static var guide: BackupV12Character {+        BackupV12Character(             id: guideID, workID: typedWorkID, name: "Grover", nameKey: "grover",             aliases: ["Klar"], note: "The guide.",             facts: [-                CharacterFact(+                RecordFact(                     statement: "Promised to guide them home.",                     quote: "promised to guide them home",                     nameKey: "grover", source: .entry(notedEntryID))@@ -743,14 +768,14 @@ enum BackupGoldenLibrary {     }      /// The sync orphan: a character whose work has not arrived.-    private static var orphan: BackupV11Character {-        BackupV11Character(+    private static var orphan: BackupV12Character {+        BackupV12Character(             id: orphanID, workID: nil, name: "The Stranger", nameKey: "the stranger",             aliases: [], note: "", facts: [], createdAt: created, modifiedAt: created)     } -    private static var candidateSuppression: BackupV11Suppression {-        BackupV11Suppression(+    private static var candidateSuppression: BackupV12Suppression {+        BackupV12Suppression(             id: candidateSuppressionID, workID: typedWorkID,             kindRaw: CharacterSuppressionKind.candidate.rawValue, nameKey: "the crowned one",             sourceKindRaw: nil, sourceEntryID: nil, evidence: nil,@@ -758,8 +783,8 @@ enum BackupGoldenLibrary {     }      /// A fact suppression, which is the shape that carries a source and evidence.-    private static var factSuppression: BackupV11Suppression {-        BackupV11Suppression(+    private static var factSuppression: BackupV12Suppression {+        BackupV12Suppression(             id: factSuppressionID, workID: typedWorkID,             kindRaw: CharacterSuppressionKind.fact.rawValue, nameKey: "grover",             sourceKindRaw: SourceRef.entry(notedEntryID).kindRaw, sourceEntryID: notedEntryID,@@ -767,6 +792,52 @@ enum BackupGoldenLibrary {             statusRaw: CharacterSuppressionStatus.active.rawValue, actionAt: created)     } +    // MARK: The places (`place-extraction` Req 5.1)++    /// The place the reader accepted: aliases, a note and a fact citing the+    /// noted Entry, so the golden pins every column of the record.+    private static var keep: BackupV12Place {+        BackupV12Place(+            id: keepID, workID: typedWorkID, name: "The High Keep",+            nameKey: "high keep", aliases: ["The Keep"],+            note: "The fortress above the pass.",+            facts: [+                RecordFact(+                    statement: "Sits above the pass.", quote: "above the pass",+                    nameKey: "high keep", source: .entry(notedEntryID))+            ],+            createdAt: created, modifiedAt: created)+    }++    /// Req 5.5's orphan. A place names its owner in a column, so "no owner" is a+    /// `workID` no record of this archive answers for — the same absent Work the+    /// orphan membership names, which keeps the fixture's cast small.+    private static var orphanPlace: BackupV12Place {+        BackupV12Place(+            id: orphanPlaceID, workID: absentWorkID, name: "The Drowned Road",+            nameKey: "drowned road", aliases: [], note: "", facts: [],+            createdAt: created, modifiedAt: created)+    }++    private static var placeCandidateSuppression: BackupV12PlaceSuppression {+        BackupV12PlaceSuppression(+            id: placeCandidateSuppressionID, workID: typedWorkID,+            kindRaw: CharacterSuppressionKind.candidate.rawValue, nameKey: "low road",+            sourceKindRaw: nil, sourceEntryID: nil, evidence: nil,+            statusRaw: CharacterSuppressionStatus.active.rawValue, actionAt: created)+    }++    /// The place fact suppression, which is the shape that carries a source and+    /// evidence.+    private static var placeFactSuppression: BackupV12PlaceSuppression {+        BackupV12PlaceSuppression(+            id: placeFactSuppressionID, workID: typedWorkID,+            kindRaw: CharacterSuppressionKind.fact.rawValue, nameKey: "high keep",+            sourceKindRaw: SourceRef.entry(notedEntryID).kindRaw, sourceEntryID: notedEntryID,+            evidence: "above the pass",+            statusRaw: CharacterSuppressionStatus.active.rawValue, actionAt: created)+    }+     // MARK: The duplicate rows      /// Two rows per application UUID, agreeing about everything the reader
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift Modified +37 / -37
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swiftindex 9d92a7d..3e9c038 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupProjectionTests.swift@@ -36,7 +36,7 @@ struct BackupGroupProjectionTests {         store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, sharedAt: 90, title: "Chapter 1")         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.entries.count == 1)         let entry = try #require(payload.entries.first)@@ -58,15 +58,15 @@ struct BackupGroupProjectionTests {         store.addEntry(id: shared, key: "chapter-1", capturedAt: 40)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }-        let encoded = try BackupV11Codec.encode(+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))          // The decode gate is the reference validator, which refuses a payload         // holding one UUID twice — the shape the projection exists to prevent         // reaching it.-        let decoded = try BackupV11Codec.decode(encoded)+        let decoded = try BackupV12Codec.decode(encoded)         #expect(decoded.payload.entries.count == 1)     } @@ -86,7 +86,7 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-2", capturedAt: 20, work: second, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.works.count == 1)         let work = try #require(payload.works.first)@@ -114,7 +114,7 @@ struct BackupGroupProjectionTests {         store.addEntry(id: entryID, key: "chapter-1", capturedAt: 30, work: second, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.entries.count == 1)         let work = try #require(payload.works.first)@@ -147,7 +147,7 @@ struct BackupGroupProjectionTests {         store.addEntry(id: shared, key: "chapter-1", capturedAt: 40, work: work, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          let entry = try #require(payload.entries.first)         let archivedWork = try #require(payload.works.first)@@ -157,10 +157,10 @@ struct BackupGroupProjectionTests {         // names none (Req 9.3), so an Entry that points nowhere is unattached and         // nothing contradicts it.         #expect(archivedWork.id == work.id)-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     }      /// The Definitions' assignment normalisation, in the export (Q106): rows@@ -195,7 +195,7 @@ struct BackupGroupProjectionTests {         rowB.editCitations { $0.workAssignment = .manual }         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.entries.count == 1)         let entry = try #require(payload.entries.first)@@ -205,10 +205,10 @@ struct BackupGroupProjectionTests {         // `entryIDs` lists is gone with the child lists (Req 9.3), and what is         // left is the Entry naming one of them.         #expect(payload.works.count == 2)-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     }      // MARK: - Req 8.3: unique-UUID set members never block export@@ -224,7 +224,7 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-1", capturedAt: 20, note: "from the laptop")         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.entries.count == 2)         #expect(Set(payload.entries.map(\.note)) == ["from the phone", "from the laptop"])@@ -242,7 +242,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 1)@@ -268,7 +268,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 2)@@ -288,7 +288,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 1)@@ -321,7 +321,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 1)@@ -357,7 +357,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 2)@@ -393,7 +393,7 @@ struct BackupGroupProjectionTests {         try store.commit()          let payload = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          #expect(payload.count == 2)@@ -414,7 +414,7 @@ struct BackupGroupProjectionTests {         try store.commit()          _ = try expectTornRefusal {-            _ = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+            _ = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         }          // The resolution outcome: both rows carry the chosen variant (Req@@ -423,7 +423,7 @@ struct BackupGroupProjectionTests {         second.note = "from the phone"         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }         #expect(payload.entries.count == 1)         #expect(payload.entries.first?.note == "from the phone")     }@@ -441,7 +441,7 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-1", capturedAt: 10, site: first)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.titlePatterns.count == 1)         #expect(payload.sites.count == 1)@@ -450,10 +450,10 @@ struct BackupGroupProjectionTests {         // The archive re-decodes: a payload holding one rule UUID twice is what         // the reference validator refuses, and what the store validates it must         // be able to export (task 20.4).-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     }      /// The dedup must not cost a hostname its active title rule.@@ -476,14 +476,14 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-1", capturedAt: 10, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.titlePatterns.count == 1)         #expect(payload.titlePatterns.first?.isActive == true)-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: DuplicateStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     }      /// The URL-rule half, which fails *silently* rather than refusing: nothing@@ -502,7 +502,7 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-1", capturedAt: 10, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.urlRules.count == 1)         #expect(payload.urlRules.first?.isCurrent == true)@@ -536,7 +536,7 @@ struct BackupGroupProjectionTests {         #expect(facts.first(where: \.isActive)?.version == 3)         #expect(try store.diagnose().quarantineMap().isEmpty) -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.titlePatterns.count == 1)         #expect(payload.titlePatterns.first?.isActive == true)@@ -558,7 +558,7 @@ struct BackupGroupProjectionTests {         store.addEntry(key: "chapter-1", capturedAt: 10, site: site)         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          #expect(payload.titlePatterns.count == 1)         #expect(payload.titlePatterns.first?.isActive == true)@@ -594,7 +594,7 @@ struct BackupGroupProjectionTests {         }         try store.commit() -        let payload = try store.read { try LibraryRepository.projectV11Payload(context: $0) }+        let payload = try store.read { try LibraryRepository.projectV12Payload(context: $0) }          let pattern = try #require(payload.titlePatterns.first)         #expect(pattern.id == ruleID)@@ -608,7 +608,7 @@ struct BackupGroupProjectionTests {             try body()             Issue.record("expected a torn-groups refusal, but the export proceeded")             return TornGroupsPayload(count: 0, blockingWorkSet: nil)-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             guard case .tornGroups(let payload) = error else {                 Issue.record("expected .tornGroups, got \(error)")                 return TornGroupsPayload(count: 0, blockingWorkSet: nil)
Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift Modified +18 / -18
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swiftindex c00bcf1..69fe9a4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupGroupRoundTripTests.swift@@ -38,7 +38,7 @@ struct BackupGroupRoundTripTests {         let entryID = UUID()         try await repository.seedSplitEntryGroup(id: entryID) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()         let record = try #require(payload.entries.first)         let before = try await repository.entryRows(id: entryID)         #expect(before.count == 2)@@ -63,7 +63,7 @@ struct BackupGroupRoundTripTests {         let sourceRepository = try await source.open()         let entryID = UUID()         try await sourceRepository.seedSplitEntryGroup(id: entryID)-        let payload = try await sourceRepository.backupV11Snapshot()+        let payload = try await sourceRepository.backupV12Snapshot()          let target = try RoundTripEnvironment()         let targetRepository = try await target.open()@@ -87,7 +87,7 @@ struct BackupGroupRoundTripTests {         let workID = UUID()         try await repository.seedSplitWorkGroup(id: workID) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()         let record = try #require(payload.works.first)         let before = try await repository.workRows(id: workID)         #expect(before.count == 2)@@ -117,7 +117,7 @@ struct BackupGroupRoundTripTests {         let sourceRepository = try await source.open()         let workID = UUID()         try await sourceRepository.seedSplitWorkGroup(id: workID)-        let payload = try await sourceRepository.backupV11Snapshot()+        let payload = try await sourceRepository.backupV12Snapshot()          let target = try RoundTripEnvironment()         let targetRepository = try await target.open()@@ -132,7 +132,7 @@ struct BackupGroupRoundTripTests {      // MARK: - Citations and rule rows (Req 3.8, 5.1, 5.3) -    /// The 8/9 claim 11/12 inherits, end to end: what an archive says about+    /// The 8/9 claim 12/13 inherits, end to end: what an archive says about     /// provenance is what a library restored from it holds.     ///     /// The fixture carries the two shapes the version invariant used to refuse —@@ -149,7 +149,7 @@ struct BackupGroupRoundTripTests {         let sourceRepository = try await source.open()         _ = try await sourceRepository.confirmImport(plan: RoundTripEnvironment.plan(payload)) -        let exported = try await sourceRepository.backupV11Snapshot()+        let exported = try await sourceRepository.backupV12Snapshot()          let target = try RoundTripEnvironment()         let targetRepository = try await target.open()@@ -157,7 +157,7 @@ struct BackupGroupRoundTripTests {          // Req 5.3's citation half, as decoded values rather than as bytes.         let original = try #require(payload.entries.first?.citations)-        let citations = try await targetRepository.citations(entryID: BackupV11Fixtures.entryID)+        let citations = try await targetRepository.citations(entryID: BackupV12Fixtures.entryID)         #expect(citations == original)         // Named, so the comparison above cannot pass on two empty values.         #expect(citations?.chapterSequence?.id == Self.citedURLRuleID)@@ -172,7 +172,7 @@ struct BackupGroupRoundTripTests {     /// used to refuse are not merely decodable, they **commit**, and the rows     /// land holding the versions the archive named.     ///-    /// `BackupV11ArchiveTests` stops at a decode, which only proves the reference+    /// `BackupV12ArchiveTests` stops at a decode, which only proves the reference     /// checks let the file through. Here the same fixture goes through     /// `confirmImport` into an empty library: two title patterns share version 1     /// with one of them marked, and the marked URL rule sits *below* a retired@@ -180,7 +180,7 @@ struct BackupGroupRoundTripTests {     /// renumbering pass would admit it and move the versions.     @Test("An archive with duplicate and non-greatest rule versions imports with its versions")     func duplicateAndNonGreatestVersionsImportIntoAnEmptyLibrary() async throws {-        let payload = BackupV11Fixtures.duplicateVersionsPayload()+        let payload = BackupV12Fixtures.duplicateVersionsPayload()         let host = try #require(payload.sites.first?.hostname)          let target = try RoundTripEnvironment()@@ -211,7 +211,7 @@ struct BackupGroupRoundTripTests {     private static let citedURLRuleID = UUID(         uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")! -    private static func expectedPatternRows(_ payload: BackupV11Payload) -> [RuleRowFacts] {+    private static func expectedPatternRows(_ payload: BackupV12Payload) -> [RuleRowFacts] {         payload.titlePatterns             .map {                 RuleRowFacts(@@ -221,7 +221,7 @@ struct BackupGroupRoundTripTests {             .sorted { $0.id.uuidString < $1.id.uuidString }     } -    private static func expectedURLRuleRows(_ payload: BackupV11Payload) -> [RuleRowFacts] {+    private static func expectedURLRuleRows(_ payload: BackupV12Payload) -> [RuleRowFacts] {         payload.urlRules             .map {                 RuleRowFacts(@@ -234,22 +234,22 @@ struct BackupGroupRoundTripTests {     /// `composedPayload` — one taught Site, one cited title rule, one cited URL     /// rule, one Entry citing both — plus a retired row of each kind sitting at     /// a higher version than the marked one.-    private static func versionSpreadPayload() -> BackupV11Payload {-        let base = BackupV11Fixtures.composedPayload()+    private static func versionSpreadPayload() -> BackupV12Payload {+        let base = BackupV12Fixtures.composedPayload()         let host = "example.com"-        let retired = BackupV11Fixtures.created.addingTimeInterval(-60)+        let retired = BackupV12Fixtures.created.addingTimeInterval(-60) -        let retiredPattern = BackupV11TitlePattern(+        let retiredPattern = BackupV12TitlePattern(             id: UUID(uuidString: "cccccccc-cccc-cccc-cccc-ccccccccccc9")!,             siteHostname: host, version: 9, isActive: false, createdAt: retired,             definition: StoredPatternDefinition(definition: .wholeTitle))-        let retiredRule = BackupV11URLRule(+        let retiredRule = BackupV12URLRule(             id: UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd9")!,             version: 7, isCurrent: false, createdAt: retired, origin: .readerTaught,             definition: .sequence(locator: .query(name: ExactScalarString("part"))),             siteHostname: host) -        return BackupV11Payload(+        return BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns + [retiredPattern],             urlRules: base.urlRules + [retiredRule],@@ -288,7 +288,7 @@ private struct RoundTripEnvironment {      /// The plan `confirmImport` takes, straight off a payload the export just     /// produced — which is what a reader restoring their own backup hands it.-    static func plan(_ payload: BackupV11Payload) -> BackupImportPlan {+    static func plan(_ payload: BackupV12Payload) -> BackupImportPlan {         BackupImportPlan(             metadata: BackupImportMetadata(                 formatVersion: 8, schemaVersion: 9, appBuild: "test-1.0",
Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift Modified +22 / -22
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swiftindex 3c0184b..cecd8ae 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupImportTransactionTests.swift@@ -67,7 +67,7 @@ struct BackupImportTransactionTests {     /// its three status fields off the record, exactly as it reads the title     /// provenance beside them — and a record carrying the defaults lands on the     /// defaults, which is the only spelling "a record without statuses" still-    /// has: 11/12 makes all three required on the wire (Q34), so the shape that+    /// has: 12/13 makes all three required on the wire (Q34), so the shape that     /// carried none of them is an 8/9 file this build refuses by version.     @Test("A materialised work carries the archive record's statuses and verdict")     func importedWorkCarriesTheStatuses() async throws {@@ -727,7 +727,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV12.self)+    let schema = Schema(versionedSchema: AsterismSchemaV13.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -736,7 +736,7 @@ private func createReadyEmptyV3Store(at configuration: LibraryConfiguration) thr     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV12MigrationPlan.self,+        migrationPlan: AsterismV13MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -750,7 +750,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV12.self)+    let schema = Schema(versionedSchema: AsterismSchemaV13.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -759,7 +759,7 @@ private func createReadyPopulatedV3Store(at configuration: LibraryConfiguration)     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV12MigrationPlan.self,+        migrationPlan: AsterismV13MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -803,7 +803,7 @@ private func createReadySiteStore(         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV12.self)+    let schema = Schema(versionedSchema: AsterismSchemaV13.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -812,7 +812,7 @@ private func createReadySiteStore(     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV12MigrationPlan.self,+        migrationPlan: AsterismV13MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -848,7 +848,7 @@ private func createReadyDuplicateSiteStore(         at: configuration.storeURL.deletingLastPathComponent(),         withIntermediateDirectories: true     )-    let schema = Schema(versionedSchema: AsterismSchemaV12.self)+    let schema = Schema(versionedSchema: AsterismSchemaV13.self)     let storeConfig = ModelConfiguration(         "AsterismV3",         schema: schema,@@ -857,7 +857,7 @@ private func createReadyDuplicateSiteStore(     )     let container = try ModelContainer(         for: schema,-        migrationPlan: AsterismV12MigrationPlan.self,+        migrationPlan: AsterismV13MigrationPlan.self,         configurations: [storeConfig]     )     let context = ModelContext(container)@@ -898,12 +898,12 @@ private func makeSiteDesignationPlan(     patternVersion: Int = 1 ) throws -> BackupImportPlan {     let epoch = Date(timeIntervalSince1970: 1_800_000_000)-    let site = BackupV11Site(+    let site = BackupV12Site(         hostname: hostname, displayName: displayName ?? hostname,         mode: mode, junkSuffixRule: junkSuffixRule)-    let patterns: [BackupV11TitlePattern] = activePattern+    let patterns: [BackupV12TitlePattern] = activePattern         ? [-            BackupV11TitlePattern(+            BackupV12TitlePattern(                 // Fixed, not minted: two applications of one archive must match                 // the same rule row rather than insert a second one.                 id: patternID,@@ -929,11 +929,11 @@ private func makeSiteDesignationPlan( private func makeBulkImportPlan(entryCount: Int) throws -> BackupImportPlan {     let hostname = "bulk.example"     let epoch = Date(timeIntervalSince1970: 1_800_000_000)-    let site = BackupV11Site(+    let site = BackupV12Site(         hostname: hostname, displayName: hostname, mode: .untaught, junkSuffixRule: nil)-    let entries = (0..<entryCount).map { index -> BackupV11Entry in+    let entries = (0..<entryCount).map { index -> BackupV12Entry in         let rawURL = "https://\(hostname)/read?chapter=\(index)"-        return BackupV11Entry(+        return BackupV12Entry(             id: UUID(), captureTitle: "Chapter \(index)", captureTitleSource: .host,             rawURL: rawURL, canonicalURL: nil, hostname: hostname,             entryIdentityKey: rawURL,@@ -992,7 +992,7 @@ private func makeMinimalImportPlan(     let patternProvenance = try FieldProvenance(         kind: .pattern, patternID: patternID) -    let entry = BackupV11Entry(+    let entry = BackupV12Entry(         id: entryID,         captureTitle: "Imported Chapter",         captureTitleSource: .networkFetch,@@ -1018,7 +1018,7 @@ private func makeMinimalImportPlan(             workAssignment: .pattern(CitedRule(id: patternID)))     ) -    let work = BackupV11Work(+    let work = BackupV12Work(         id: workID,         displayTitle: "Imported Work",         lastParsedTitle: "Imported Work",@@ -1036,7 +1036,7 @@ private func makeMinimalImportPlan(      // Req 9.1: the Work's site presence is its membership, and Req 9.5 requires     // one on the Entry's hostname.-    let membership = BackupV11Membership(+    let membership = BackupV12Membership(         // Derived from the Work rather than minted: two archives *of one         // library* carry the same membership row, which is what makes a         // re-import an update rather than a second row on the same hostname.@@ -1050,7 +1050,7 @@ private func makeMinimalImportPlan(         workURLString: workURL     ) -    let pattern = BackupV11TitlePattern(+    let pattern = BackupV12TitlePattern(         id: patternID,         siteHostname: siteHostname,         version: 1,@@ -1062,8 +1062,8 @@ private func makeMinimalImportPlan(                 ignored: []))     ) -    let urlRules: [BackupV11URLRule] = includeURLRule ? [-        BackupV11URLRule(+    let urlRules: [BackupV12URLRule] = includeURLRule ? [+        BackupV12URLRule(             id: urlRuleID,             version: 1,             isCurrent: true,@@ -1079,7 +1079,7 @@ private func makeMinimalImportPlan(         )     ] : [] -    let site = BackupV11Site(+    let site = BackupV12Site(         hostname: siteHostname,         displayName: siteHostname,         mode: .taught,
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swift Renamed +1200 / -680
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11ArchiveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swiftsimilarity index 58%rename from Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11ArchiveTests.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swiftindex 57d8603..1505d93 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11ArchiveTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12ArchiveTests.swift@@ -4,16 +4,23 @@ import Testing  @testable import AsterismCore -// Archive generation 11/12 (T-2316, `work-creators` Req 9): the payload carries-// a creator table, a role list and a credit table, and the schema number names-// the store the archive was taken from (V12). The records are otherwise 10/11's-// — a Work carries its series membership, its two statuses and its verdict, an-// Entry citation is the cited rule's UUID alone, a Work's site presence is a-// membership record, the reader's dismissed pairs and related-work links travel-// beside it, no parent record names its children, and the coverage table is-// folded onto the records that own it. It **replaces** 10/11 outright: a 10/11-// file holds no credit, so the only thing this build could do with one is invent-// the absence of every credit the reader entered.+// Archive generation 12/13 (T-2276, `place-extraction` Req 5.1): the payload+// carries a place table and a place-suppression table, and the schema number+// names the store the archive was taken from (V13). The records are otherwise+// 11/12's — a Work carries its series membership, its two statuses and its+// verdict, an Entry citation is the cited rule's UUID alone, a Work's site+// presence is a membership record, the reader's dismissed pairs and related-work+// links travel beside it, the creators, roles and credits travel whole, no+// parent record names its children, and the coverage table is folded onto the+// records that own it. It **replaces** 11/12 outright (Q51): an 11/12 file holds+// no place, so the only thing this build could do with one is invent the absence+// of every place the reader accepted.+//+// A place record is the character record with a **non-optional** `workID`: a+// place names its owner in a column rather than through a relationship, so+// "no owner" is a UUID nothing resolves rather than an absent one (Q60, Q67).+// An unresolvable `workID` is therefore kept on import and exported as it+// stands, where a character naming a work the file does not carry is a refusal. // // Three suites, because the generation has three surfaces and they fail // differently: the codec answers for the wire shape and its refusals, the@@ -22,31 +29,33 @@ import Testing  // MARK: - Codec -@Suite("Backup V11 codec")-struct BackupV11CodecTests {+@Suite("Backup V12 codec")+struct BackupV12CodecTests { -    @Test("V11 encode/decode round-trips 11/12, the multi-site gate, and the fifteen arrays")+    @Test("V12 encode/decode round-trips 12/13, the multi-site gate, and the seventeen arrays")     func roundTrip() throws {-        let payload = BackupV11Fixtures.payload()+        let payload = BackupV12Fixtures.payload() -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata())) -        #expect(decoded.backupFormatVersion == 11)-        #expect(decoded.databaseSchemaVersion == 12)+        #expect(decoded.backupFormatVersion == 12)+        #expect(decoded.databaseSchemaVersion == 13)         #expect(decoded.capabilityGate == "multi-site")         #expect(decoded.payload == payload)         #expect(decoded.payload.characters == payload.characters)         #expect(decoded.payload.suppressions == payload.suppressions)+        #expect(decoded.payload.places == payload.places)+        #expect(decoded.payload.placeSuppressions == payload.placeSuppressions)         #expect(decoded.payload.memberships == payload.memberships)         // Req 9.4: the coverage table is gone and the fingerprints ride on the         // records whose text they describe.         #expect(             decoded.payload.entries.first?.characterExtractionFingerprint-                == BackupV11Fixtures.noteFingerprint)+                == BackupV12Fixtures.noteFingerprint)         #expect(             decoded.payload.works.first?.genericNotesExtractionFingerprint-                == BackupV11Fixtures.genericNotesFingerprint)+                == BackupV12Fixtures.genericNotesFingerprint)     }      /// Req 8.1. The two statuses travel as the typed enums, exactly as@@ -55,13 +64,13 @@ struct BackupV11CodecTests {     /// defaults, so a dropped field cannot pass as a matching default.     @Test("A Work's two statuses and verdict survive the round-trip typed")     func workStatusFieldsRoundTrip() throws {-        let payload = BackupV11Fixtures.composedPayload()+        let payload = BackupV12Fixtures.composedPayload() -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          let record = try #require(-            decoded.payload.works.first { $0.id == BackupV11Fixtures.composedWorkID })+            decoded.payload.works.first { $0.id == BackupV12Fixtures.composedWorkID })         #expect(record.workStatus == .finished)         #expect(record.readingStatus == .abandoned)         #expect(record.verdict == "Dropped it at the timeskip.")@@ -74,18 +83,18 @@ struct BackupV11CodecTests {     @Test("A character's facts, aliases, note and keys survive the round-trip")     func characterFieldsRoundTrip() throws {         let facts = [-            BackupV11Fixtures.fact(),-            BackupV11Fixtures.fact(+            BackupV12Fixtures.fact(),+            BackupV12Fixtures.fact(                 statement: "Knows the way through the pass.",                 quote: "knows the way", source: .genericNotes),         ]-        let payload = BackupV11Fixtures.payload(+        let payload = BackupV12Fixtures.payload(             characters: [-                BackupV11Fixtures.character(aliases: ["Klar", "The Guide"], facts: facts)+                BackupV12Fixtures.character(aliases: ["Klar", "The Guide"], facts: facts)             ]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          let character = try #require(decoded.payload.characters.first)         #expect(character.name == "Grover")@@ -94,23 +103,23 @@ struct BackupV11CodecTests {         #expect(character.note == "The guide.")         #expect(character.facts.count == 2)         #expect(character.facts.contains { $0.source == .genericNotes })-        #expect(character.facts.contains { $0.source == .entry(BackupV11Fixtures.entryID) })+        #expect(character.facts.contains { $0.source == .entry(BackupV12Fixtures.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 = [-            BackupV11Fixtures.suppression(),-            BackupV11Fixtures.suppression(-                id: BackupV11Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",-                source: .entry(BackupV11Fixtures.entryID), evidence: "promised to guide",+            BackupV12Fixtures.suppression(),+            BackupV12Fixtures.suppression(+                id: BackupV12Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+                source: .entry(BackupV12Fixtures.entryID), evidence: "promised to guide",                 status: .cleared),         ]-        let payload = BackupV11Fixtures.payload(suppressions: rows)+        let payload = BackupV12Fixtures.payload(suppressions: rows) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.suppressions == rows)     }@@ -123,11 +132,11 @@ struct BackupV11CodecTests {     /// tolerated in-flight state (Req 6.7).     @Test("A character with no work reference validates")     func orphanCharacterValidates() throws {-        let payload = BackupV11Fixtures.payload(-            characters: [BackupV11Fixtures.character(id: BackupV11Fixtures.orphanID, workID: nil)])+        let payload = BackupV12Fixtures.payload(+            characters: [BackupV12Fixtures.character(id: BackupV12Fixtures.orphanID, workID: nil)]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.characters.first?.workID == nil)     }@@ -139,18 +148,18 @@ struct BackupV11CodecTests {     @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 = BackupV11Fixtures.payload(+        let payload = BackupV12Fixtures.payload(             characters: [-                BackupV11Fixtures.character(facts: [BackupV11Fixtures.fact(source: .entry(absent))])+                BackupV12Fixtures.character(facts: [BackupV12Fixtures.fact(source: .entry(absent))])             ],             suppressions: [-                BackupV11Fixtures.suppression(-                    id: BackupV11Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",+                BackupV12Fixtures.suppression(+                    id: BackupV12Fixtures.factSuppressionID, kind: .fact, nameKey: "grover",                     source: .entry(absent), evidence: "gone")             ]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.characters.first?.facts.first?.source == .entry(absent))         #expect(decoded.payload.suppressions.first?.sourceEntryID == absent)@@ -161,24 +170,24 @@ struct BackupV11CodecTests {     @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 = BackupV11Fixtures.payload(-            characters: [BackupV11Fixtures.character(workID: absent)])+        let payload = BackupV12Fixtures.payload(+            characters: [BackupV12Fixtures.character(workID: absent)])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.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 = BackupV11Fixtures.payload(-            suppressions: [BackupV11Fixtures.suppression(workID: absent)])+        let payload = BackupV12Fixtures.payload(+            suppressions: [BackupV12Fixtures.suppression(workID: absent)])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -186,26 +195,137 @@ struct BackupV11CodecTests {      @Test("Two records for one character identity refuse")     func duplicateCharacterIDRefuses() throws {-        let payload = BackupV11Fixtures.payload(-            characters: [BackupV11Fixtures.character(), BackupV11Fixtures.character()])+        let payload = BackupV12Fixtures.payload(+            characters: [BackupV12Fixtures.character(), BackupV12Fixtures.character()])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     }      @Test("Two records for one suppression identity refuse")     func duplicateSuppressionIDRefuses() throws {-        let payload = BackupV11Fixtures.payload(-            suppressions: [BackupV11Fixtures.suppression(), BackupV11Fixtures.suppression()])+        let payload = BackupV12Fixtures.payload(+            suppressions: [BackupV12Fixtures.suppression(), BackupV12Fixtures.suppression()])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } +    // MARK: The two place arrays (Req 5.1, 5.3)++    /// The place record is the character record with a non-optional `workID`, so+    /// every field has to survive the round trip the same way — asserted after a+    /// real encode/decode rather than trusted to the shared `Codable` shape.+    @Test("A place's facts, aliases, note and keys survive the round-trip")+    func placeFieldsRoundTrip() throws {+        let facts = [+            BackupV12Fixtures.fact(+                statement: "Sits above the pass.", quote: "above the pass",+                nameKey: "high keep"),+            BackupV12Fixtures.fact(+                statement: "Was abandoned after the siege.", quote: "abandoned after the siege",+                nameKey: "high keep", source: .genericNotes),+        ]+        let payload = BackupV12Fixtures.payload(+            places: [BackupV12Fixtures.place(aliases: ["The Keep"], facts: facts)])++        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))++        let place = try #require(decoded.payload.places.first)+        #expect(place.workID == BackupV12Fixtures.workID)+        #expect(place.name == "The High Keep")+        #expect(place.nameKey == "high keep")+        #expect(place.aliases == ["The Keep"])+        #expect(place.note == "The fortress above the pass.")+        #expect(place.facts.count == 2)+        #expect(place.facts.contains { $0.source == .genericNotes })+        #expect(place.facts.contains { $0.source == .entry(BackupV12Fixtures.entryID) })+    }++    @Test("Both place-suppression kinds round-trip with their status and action time")+    func placeSuppressionKindsRoundTrip() throws {+        let rows = [+            BackupV12Fixtures.placeSuppression(),+            BackupV12Fixtures.placeSuppression(+                id: BackupV12Fixtures.placeFactSuppressionID, kind: .fact,+                nameKey: "high keep", source: .entry(BackupV12Fixtures.entryID),+                evidence: "above the pass", status: .cleared),+        ]+        let payload = BackupV12Fixtures.payload(placeSuppressions: rows)++        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))++        #expect(decoded.payload.placeSuppressions == rows)+    }++    /// Q60 and Q67, on the wire. A place names its owner in a column, so the+    /// absence a character expresses with `nil` is a UUID nothing resolves —+    /// and the file has to carry it rather than refuse over it, or a sync orphan+    /// (Req 5.5) could never be backed up.+    @Test("A place and a place suppression naming a work the archive does not carry validate")+    func orphanPlaceValidates() throws {+        let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-00000000000a")!+        let payload = BackupV12Fixtures.payload(+            places: [BackupV12Fixtures.place(id: BackupV12Fixtures.orphanPlaceID, workID: absent)],+            placeSuppressions: [BackupV12Fixtures.placeSuppression(workID: absent)])++        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))++        #expect(decoded.payload.places.first?.workID == absent)+        #expect(decoded.payload.placeSuppressions.first?.workID == absent)+    }++    /// The half of the character rule the places keep: a payload holding two+    /// records for one identity contradicts itself whichever table it is.+    @Test("Two records for one place or one place-suppression identity refuse")+    func duplicatePlaceIDRefuses() throws {+        let duplicatePlaces = BackupV12Fixtures.payload(+            places: [BackupV12Fixtures.place(), BackupV12Fixtures.place()])+        #expect(throws: BackupCodecError.self) {+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: duplicatePlaces, metadata: BackupV12Fixtures.metadata()))+        }++        let duplicateSuppressions = BackupV12Fixtures.payload(+            placeSuppressions: [+                BackupV12Fixtures.placeSuppression(), BackupV12Fixtures.placeSuppression(),+            ])+        #expect(throws: BackupCodecError.self) {+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: duplicateSuppressions, metadata: BackupV12Fixtures.metadata()))+        }+    }++    /// The fourth thing a schema bump moves (Q66, phase 2 review). The three+    /// others fail loudly when they are forgotten — the store will not open, the+    /// snapshot will not compile, the marker refuses. A forgotten archive+    /// generation is silent: the app keeps exporting a file that *claims* to be+    /// taken from the previous schema and omits whatever the bump added. So the+    /// envelope's numbers are pinned to the live schema rather than restated.+    @Test("The archive's schema version is the live schema's, and the format is one below")+    func envelopeVersionsFollowTheLiveSchema() throws {+        #expect(+            BackupV12Document.schemaVersion == AsterismSchemaV13.versionIdentifier.major,+            """+            the archive declares schema \(BackupV12Document.schemaVersion) but the store \+            is at V\(AsterismSchemaV13.versionIdentifier.major). A schema bump moves four \+            things (CLAUDE.md, `docs/agent-notes/schema-migration.md`), and the archive \+            generation is the one that fails silently when it is forgotten.+            """)+        #expect(+            BackupV12Document.formatVersion == BackupV12Document.schemaVersion - 1,+            "the format number has trailed the schema number by one since 4/4 diverged")+    }+     // MARK: The two Req 9.5 membership refusals      /// Req 9.5, first half. An Entry's Work is in the file and holds no@@ -214,20 +334,20 @@ struct BackupV11CodecTests {     /// wholly legal on arrival (Q50).     @Test("An Entry whose present Work has no membership on its hostname refuses")     func entryWithoutAMembershipOnItsHostnameRefuses() throws {-        let base = BackupV11Fixtures.composedPayload()+        let base = BackupV12Fixtures.composedPayload()          // The premise: with the membership present the payload is legal.-        _ = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: base, metadata: BackupV11Fixtures.metadata()))+        _ = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: base, metadata: BackupV12Fixtures.metadata())) -        let uncovered = BackupV11Payload(+        let uncovered = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: [])         let error = #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: uncovered, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: uncovered, metadata: BackupV12Fixtures.metadata()))         }         guard case .unresolvedReference(let type, _, let reference) = error else {             Issue.record("expected an unresolved reference, got \(String(describing: error))")@@ -242,18 +362,18 @@ struct BackupV11CodecTests {     /// the reconciler resolves (Req 2.6, 8.2), and one an archive may not carry.     @Test("Two memberships for one Work and hostname refuse")     func duplicateMembershipForOneHostnameRefuses() throws {-        let base = BackupV11Fixtures.composedPayload()-        let twin = BackupV11Fixtures.membership(+        let base = BackupV12Fixtures.composedPayload()+        let twin = BackupV12Fixtures.membership(             id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeee2")!,-            workID: BackupV11Fixtures.composedWorkID, hostname: "example.com")-        let payload = BackupV11Payload(+            workID: BackupV12Fixtures.composedWorkID, hostname: "example.com")+        let payload = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: base.memberships + [twin])          let error = #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }         guard case .invalidStateTuple(let type, _, let reason) = error else {             Issue.record("expected an invalid state tuple, got \(String(describing: error))")@@ -270,24 +390,24 @@ struct BackupV11CodecTests {     func unattachedMembershipAndPairValidate() throws {         let absent = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000010")!         let other = UUID(uuidString: "DEADBEEF-0000-4000-8000-000000000011")!-        let base = BackupV11Fixtures.composedPayload()-        let orphan = BackupV11Fixtures.membership(+        let base = BackupV12Fixtures.composedPayload()+        let orphan = BackupV12Fixtures.membership(             id: UUID(uuidString: "0adbea00-0000-4000-8000-000000000001")!,             workID: absent, hostname: "example.com")         let ids = WorkDistinctPair.sortedIDs(absent, other)-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: base.memberships + [orphan],             distinctPairs: [-                BackupV11DistinctPair(+                BackupV12DistinctPair(                     id: UUID(uuidString: "0adbea00-0000-4000-8000-000000000002")!,                     lowerWorkID: ids.lower, higherWorkID: ids.higher,-                    recordedAt: BackupV11Fixtures.created)+                    recordedAt: BackupV12Fixtures.created)             ]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.memberships.contains { $0.workID == absent })         #expect(decoded.payload.distinctPairs.count == 1)@@ -298,20 +418,20 @@ struct BackupV11CodecTests {     /// value (Req 1.2).     @Test("A membership whose identity tuple contradicts itself refuses")     func illegalMembershipTupleRefuses() throws {-        let base = BackupV11Fixtures.composedPayload()-        let illegal = BackupV11Membership(-            id: BackupV11Fixtures.composedMembershipID,-            workID: BackupV11Fixtures.composedWorkID, hostname: "example.com",-            createdAt: BackupV11Fixtures.created, urlIdentity: nil, urlIdentityState: .rule,+        let base = BackupV12Fixtures.composedPayload()+        let illegal = BackupV12Membership(+            id: BackupV12Fixtures.composedMembershipID,+            workID: BackupV12Fixtures.composedWorkID, hostname: "example.com",+            createdAt: BackupV12Fixtures.created, urlIdentity: nil, urlIdentityState: .rule,             urlIdentityRuleID: nil, workURLString: nil)-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: [illegal])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -321,29 +441,29 @@ struct BackupV11CodecTests {     /// no writer produces — the Entry's identity arm refuses the same shape.     @Test("A membership citing a rule taught for another site refuses")     func membershipCitingAnotherSitesRuleRefuses() throws {-        let base = BackupV11Fixtures.composedPayload()+        let base = BackupV12Fixtures.composedPayload()         let otherHost = "other.example"         let otherRuleID = UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd2")!-        let otherSite = BackupV11Site(+        let otherSite = BackupV12Site(             hostname: otherHost, displayName: "Other", mode: .untaught, junkSuffixRule: nil)-        let otherRule = BackupV11URLRule(-            id: otherRuleID, version: 1, isCurrent: false, createdAt: BackupV11Fixtures.created,+        let otherRule = BackupV12URLRule(+            id: otherRuleID, version: 1, isCurrent: false, createdAt: BackupV12Fixtures.created,             origin: .importedV2,             definition: .work(locator: .query(name: ExactScalarString("series"))),             siteHostname: otherHost)         // The membership is on example.com and cites other.example's rule.-        let crossSite = BackupV11Fixtures.membership(-            id: BackupV11Fixtures.composedMembershipID,-            workID: BackupV11Fixtures.composedWorkID, hostname: "example.com",+        let crossSite = BackupV12Fixtures.membership(+            id: BackupV12Fixtures.composedMembershipID,+            workID: BackupV12Fixtures.composedWorkID, hostname: "example.com",             urlIdentity: "serial-9", urlIdentityState: .rule, urlIdentityRuleID: otherRuleID)-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites + [otherSite],             titlePatterns: base.titlePatterns, urlRules: base.urlRules + [otherRule],             workTypes: base.workTypes, memberships: [crossSite])          let error = #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }         guard case .invalidStateTuple(let type, _, let reason) = error else {             Issue.record("expected an invalid state tuple, got \(String(describing: error))")@@ -359,64 +479,64 @@ struct BackupV11CodecTests {     /// arrives, which is a tolerated state rather than a corrupt file.     @Test("A membership citing a rule the archive does not carry is accepted")     func membershipCitingAnAbsentRuleValidates() throws {-        let base = BackupV11Fixtures.composedPayload()+        let base = BackupV12Fixtures.composedPayload()         let absentRule = UUID(uuidString: "dddddddd-dddd-dddd-dddd-ddddddddddd3")!-        let dangling = BackupV11Fixtures.membership(-            id: BackupV11Fixtures.composedMembershipID,-            workID: BackupV11Fixtures.composedWorkID, hostname: "example.com",+        let dangling = BackupV12Fixtures.membership(+            id: BackupV12Fixtures.composedMembershipID,+            workID: BackupV12Fixtures.composedWorkID, hostname: "example.com",             urlIdentity: "serial-9", urlIdentityState: .rule, urlIdentityRuleID: absentRule)-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: [dangling]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.memberships.first?.urlIdentityRuleID == absentRule)     }      // MARK: Series and links (`series-and-related-works` Req 13.5) -    /// The whole V11 surface survives a real round trip: the series table, both+    /// The whole V12 surface survives a real round trip: the series table, both     /// works' membership pairs, and the link between them.     @Test("Series, memberships and links round-trip through the codec")     func seriesAndLinksRoundTrip() throws {-        let payload = BackupV11Fixtures.seriesPayload()+        let payload = BackupV12Fixtures.seriesPayload() -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.series == payload.series)         #expect(decoded.payload.links == payload.links)         let first = try #require(-            decoded.payload.works.first { $0.id == BackupV11Fixtures.composedWorkID })-        #expect(first.seriesID == BackupV11Fixtures.seriesID)+            decoded.payload.works.first { $0.id == BackupV12Fixtures.composedWorkID })+        #expect(first.seriesID == BackupV12Fixtures.seriesID)         #expect(first.seriesPosition == 1)         let second = try #require(-            decoded.payload.works.first { $0.id == BackupV11Fixtures.secondWorkID })+            decoded.payload.works.first { $0.id == BackupV12Fixtures.secondWorkID })         #expect(second.seriesPosition == 2.5)     }      @Test("Two records for one series identity refuse")     func duplicateSeriesIDRefuses() throws {-        let payload = BackupV11Fixtures.seriesPayload(-            series: [BackupV11Fixtures.seriesRecord(), BackupV11Fixtures.seriesRecord()])+        let payload = BackupV12Fixtures.seriesPayload(+            series: [BackupV12Fixtures.seriesRecord(), BackupV12Fixtures.seriesRecord()])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     }      @Test("Two records for one link identity refuse")     func duplicateLinkIDRefuses() throws {-        let payload = BackupV11Fixtures.seriesPayload(-            links: [BackupV11Fixtures.linkRecord(), BackupV11Fixtures.linkRecord()])+        let payload = BackupV12Fixtures.seriesPayload(+            links: [BackupV12Fixtures.linkRecord(), BackupV12Fixtures.linkRecord()])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -425,15 +545,15 @@ struct BackupV11CodecTests {     /// before a file exists; this answers for an archive written elsewhere.     @Test("A link naming one work twice refuses")     func selfLinkRefuses() throws {-        let payload = BackupV11Fixtures.seriesPayload(+        let payload = BackupV12Fixtures.seriesPayload(             links: [-                BackupV11Fixtures.linkRecord(-                    a: BackupV11Fixtures.composedWorkID, b: BackupV11Fixtures.composedWorkID)+                BackupV12Fixtures.linkRecord(+                    a: BackupV12Fixtures.composedWorkID, b: BackupV12Fixtures.composedWorkID)             ])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -443,31 +563,31 @@ struct BackupV11CodecTests {     @Test("Two links over one pair refuse, whichever order their ids are in")     func twoLinksForOnePairRefuse() throws {         let second = UUID(uuidString: "11115E51-0000-4000-8000-000000000002")!-        let payload = BackupV11Fixtures.seriesPayload(+        let payload = BackupV12Fixtures.seriesPayload(             links: [-                BackupV11Fixtures.linkRecord(),+                BackupV12Fixtures.linkRecord(),                 // The reversed spelling of the same pair: the payload sorts at                 // the door, so this is the same key rather than a second one.-                BackupV11Fixtures.linkRecord(-                    id: second, a: BackupV11Fixtures.secondWorkID,-                    b: BackupV11Fixtures.composedWorkID, type: "sequel"),+                BackupV12Fixtures.linkRecord(+                    id: second, a: BackupV12Fixtures.secondWorkID,+                    b: BackupV12Fixtures.composedWorkID, type: "sequel"),             ])          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     }      @Test("A series with an empty trimmed name refuses")     func emptySeriesNameRefuses() throws {         for name in ["", "   ", "\n\t "] {-            let payload = BackupV11Fixtures.seriesPayload(-                series: [BackupV11Fixtures.seriesRecord(name: name)])+            let payload = BackupV12Fixtures.seriesPayload(+                series: [BackupV12Fixtures.seriesRecord(name: name)])             #expect(throws: BackupCodecError.self) {-                try BackupV11Codec.decode(-                    try BackupV11Codec.encode(-                        payload: payload, metadata: BackupV11Fixtures.metadata()))+                try BackupV12Codec.decode(+                    try BackupV12Codec.encode(+                        payload: payload, metadata: BackupV12Fixtures.metadata()))             }         }     }@@ -479,16 +599,16 @@ struct BackupV11CodecTests {     @Test("A half-set membership pair refuses, either half")     func halfSetMembershipRefuses() throws {         let halves: [(UUID?, Double?)] = [-            (BackupV11Fixtures.seriesID, nil),+            (BackupV12Fixtures.seriesID, nil),             (nil, 2),         ]         for (id, position) in halves {-            let payload = BackupV11Fixtures.payloadWithMembership(+            let payload = BackupV12Fixtures.payloadWithMembership(                 seriesID: id, position: position)             #expect(throws: BackupCodecError.self) {-                try BackupV11Codec.decode(-                    try BackupV11Codec.encode(-                        payload: payload, metadata: BackupV11Fixtures.metadata()))+                try BackupV12Codec.decode(+                    try BackupV12Codec.encode(+                        payload: payload, metadata: BackupV12Fixtures.metadata()))             }         }     }@@ -498,12 +618,12 @@ struct BackupV11CodecTests {     @Test("A position that is not finite or carries a second fraction digit refuses")     func illegalPositionRefuses() throws {         for position in [2.55, 1.0 / 3.0, Double.infinity, Double.nan] {-            let payload = BackupV11Fixtures.payloadWithMembership(-                seriesID: BackupV11Fixtures.seriesID, position: position)+            let payload = BackupV12Fixtures.payloadWithMembership(+                seriesID: BackupV12Fixtures.seriesID, position: position)             #expect(throws: (any Error).self) {-                try BackupV11Codec.decode(-                    try BackupV11Codec.encode(-                        payload: payload, metadata: BackupV11Fixtures.metadata()))+                try BackupV12Codec.decode(+                    try BackupV12Codec.encode(+                        payload: payload, metadata: BackupV12Fixtures.metadata()))             }         }     }@@ -517,19 +637,19 @@ struct BackupV11CodecTests {     func unresolvedReferencesValidate() throws {         let absentSeries = UUID(uuidString: "5E81E5A0-0000-4000-8000-000000000009")!         let absentWork = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee99")!-        let payload = BackupV11Fixtures.seriesPayload(+        let payload = BackupV12Fixtures.seriesPayload(             links: [-                BackupV11Fixtures.linkRecord(-                    a: BackupV11Fixtures.composedWorkID, b: absentWork, type: "spin-off")+                BackupV12Fixtures.linkRecord(+                    a: BackupV12Fixtures.composedWorkID, b: absentWork, type: "spin-off")             ],             firstMembership: (absentSeries, 3)) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(             decoded.payload.works.contains {-                $0.id == BackupV11Fixtures.composedWorkID && $0.seriesID == absentSeries+                $0.id == BackupV12Fixtures.composedWorkID && $0.seriesID == absentSeries             })         #expect(             decoded.payload.links.contains {@@ -544,11 +664,11 @@ struct BackupV11CodecTests {     @Test("A link's ids are sorted at the door")     func linkIDsAreSortedAtTheDoor() throws {         let ids = WorkDistinctPair.sortedIDs(-            BackupV11Fixtures.composedWorkID, BackupV11Fixtures.secondWorkID)-        let reversed = BackupV11Fixtures.linkRecord(a: ids.higher, b: ids.lower)+            BackupV12Fixtures.composedWorkID, BackupV12Fixtures.secondWorkID)+        let reversed = BackupV12Fixtures.linkRecord(a: ids.higher, b: ids.lower)         #expect(reversed.lowerWorkID == ids.higher) -        let payload = BackupImportPayload(BackupV11Fixtures.seriesPayload(links: [reversed]))+        let payload = BackupImportPayload(BackupV12Fixtures.seriesPayload(links: [reversed]))         #expect(payload.links.map(\.lowerWorkID) == [ids.lower])         #expect(payload.links.map(\.higherWorkID) == [ids.higher])     }@@ -560,77 +680,77 @@ struct BackupV11CodecTests {     /// (Q50) and the list order a credit is presented in.     @Test("Creators, roles and credits round-trip with their per-field timestamps")     func creatorRecordsRoundTrip() throws {-        let payload = BackupV11Fixtures.creditsPayload()+        let payload = BackupV12Fixtures.creditsPayload() -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload.creators == payload.creators)         #expect(decoded.payload.creatorRoles == payload.creatorRoles)         #expect(decoded.payload.credits == payload.credits)          let alias = try #require(-            decoded.payload.creators.first { $0.id == BackupV11Fixtures.aliasID })+            decoded.payload.creators.first { $0.id == BackupV12Fixtures.aliasID })         #expect(alias.stateRaw == CreatorState.merged.rawValue)-        #expect(alias.canonicalID == BackupV11Fixtures.moriID)+        #expect(alias.canonicalID == BackupV12Fixtures.moriID)          let mori = try #require(-            decoded.payload.creators.first { $0.id == BackupV11Fixtures.moriID })+            decoded.payload.creators.first { $0.id == BackupV12Fixtures.moriID })         #expect(mori.notes == "Also draws.")-        #expect(mori.nameModifiedAt == BackupV11Fixtures.created)-        #expect(mori.notesModifiedAt == BackupV11Fixtures.created)+        #expect(mori.nameModifiedAt == BackupV12Fixtures.created)+        #expect(mori.notesModifiedAt == BackupV12Fixtures.created)          // A seeded role travels pristine, which is what lets the archive's own         // record of a default yield to — or beat — a local seed on arrival (Q28).         let author = try #require(-            decoded.payload.creatorRoles.first { $0.id == BackupV11Fixtures.authorRoleID })+            decoded.payload.creatorRoles.first { $0.id == BackupV12Fixtures.authorRoleID })         #expect(author.position == 0)-        #expect(author.nameModifiedAt == BackupV11Fixtures.pristine)-        #expect(author.positionModifiedAt == BackupV11Fixtures.pristine)+        #expect(author.nameModifiedAt == BackupV12Fixtures.pristine)+        #expect(author.positionModifiedAt == BackupV12Fixtures.pristine)          let letterer = try #require(-            decoded.payload.creatorRoles.first { $0.id == BackupV11Fixtures.lettererRoleID })+            decoded.payload.creatorRoles.first { $0.id == BackupV12Fixtures.lettererRoleID })         #expect(letterer.position == 3)         let editor = try #require(-            decoded.payload.creatorRoles.first { $0.id == BackupV11Fixtures.editorRoleID })+            decoded.payload.creatorRoles.first { $0.id == BackupV12Fixtures.editorRoleID })         #expect(editor.stateRaw == CreatorRoleState.removed.rawValue)          // Q27: a credit carries every role identifier it holds **in any state**,         // the removed one included, or a restore after a remove-then-restore         // would lose the pairing Req 2.2 promises to bring back.         let credit = try #require(-            decoded.payload.credits.first { $0.id == BackupV11Fixtures.moriCreditID })-        #expect(credit.roleIDs.contains(BackupV11Fixtures.editorRoleID.uuidString))+            decoded.payload.credits.first { $0.id == BackupV12Fixtures.moriCreditID })+        #expect(credit.roleIDs.contains(BackupV12Fixtures.editorRoleID.uuidString))     }      @Test(         "Two records for one creator, role or credit identity refuse",         arguments: [0, 1, 2])     func duplicateDirectoryIDsRefuse(table: Int) throws {-        let payload: BackupV11Payload+        let payload: BackupV12Payload         switch table {         case 0:-            payload = BackupV11Fixtures.creditsPayload(-                creators: BackupV11Fixtures.creatorRecords-                    + [BackupV11Fixtures.creatorRecord(-                        id: BackupV11Fixtures.moriID, name: "Mori Ayane")])+            payload = BackupV12Fixtures.creditsPayload(+                creators: BackupV12Fixtures.creatorRecords+                    + [BackupV12Fixtures.creatorRecord(+                        id: BackupV12Fixtures.moriID, name: "Mori Ayane")])         case 1:-            payload = BackupV11Fixtures.creditsPayload(-                creatorRoles: BackupV11Fixtures.creatorRoleRecords-                    + [BackupV11Fixtures.creatorRoleRecord(-                        id: BackupV11Fixtures.lettererRoleID, name: "letterer", position: 9)])+            payload = BackupV12Fixtures.creditsPayload(+                creatorRoles: BackupV12Fixtures.creatorRoleRecords+                    + [BackupV12Fixtures.creatorRoleRecord(+                        id: BackupV12Fixtures.lettererRoleID, name: "letterer", position: 9)])         default:-            payload = BackupV11Fixtures.creditsPayload(-                credits: BackupV11Fixtures.creditRecords-                    + [BackupV11Fixtures.creditRecord(-                        id: BackupV11Fixtures.moriCreditID,-                        workID: BackupV11Fixtures.secondWorkID,-                        creatorID: BackupV11Fixtures.studioID)])+            payload = BackupV12Fixtures.creditsPayload(+                credits: BackupV12Fixtures.creditRecords+                    + [BackupV12Fixtures.creditRecord(+                        id: BackupV12Fixtures.moriCreditID,+                        workID: BackupV12Fixtures.secondWorkID,+                        creatorID: BackupV12Fixtures.studioID)])         }          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -641,25 +761,25 @@ struct BackupV11CodecTests {     /// name.     @Test("Two active creators, or two visible roles, with one normalized name refuse")     func normalizedNameCollisionsRefuse() throws {-        let creators = BackupV11Fixtures.creditsPayload(-            creators: BackupV11Fixtures.creatorRecords-                + [BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.absentCreatorID, name: "MORI AYANE")])+        let creators = BackupV12Fixtures.creditsPayload(+            creators: BackupV12Fixtures.creatorRecords+                + [BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.absentCreatorID, name: "MORI AYANE")])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: creators, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: creators, metadata: BackupV12Fixtures.metadata()))         }          // Active against removed: the pair Req 2.2's restore could not choose         // between.-        let roles = BackupV11Fixtures.creditsPayload(-            creatorRoles: BackupV11Fixtures.creatorRoleRecords-                + [BackupV11Fixtures.creatorRoleRecord(-                    id: BackupV11Fixtures.absentRoleID, name: "Editor", position: 5)])+        let roles = BackupV12Fixtures.creditsPayload(+            creatorRoles: BackupV12Fixtures.creatorRoleRecords+                + [BackupV12Fixtures.creatorRoleRecord(+                    id: BackupV12Fixtures.absentRoleID, name: "Editor", position: 5)])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: roles, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: roles, metadata: BackupV12Fixtures.metadata()))         }     } @@ -669,46 +789,46 @@ struct BackupV11CodecTests {     /// whose end is another alias (Q33).     @Test("A merged record naming an absent or merged survivor refuses")     func brokenAliasChainsRefuse() throws {-        let absentSurvivor = BackupV11Fixtures.creditsPayload(+        let absentSurvivor = BackupV12Fixtures.creditsPayload(             creators: [-                BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.moriID, name: "Mori Ayane"),-                BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.aliasID, name: "mori ayane", state: .merged,-                    canonicalID: BackupV11Fixtures.absentCreatorID),+                BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.moriID, name: "Mori Ayane"),+                BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.aliasID, name: "mori ayane", state: .merged,+                    canonicalID: BackupV12Fixtures.absentCreatorID),             ])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: absentSurvivor, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: absentSurvivor, metadata: BackupV12Fixtures.metadata()))         } -        let chain = BackupV11Fixtures.creditsPayload(+        let chain = BackupV12Fixtures.creditsPayload(             creators: [-                BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.moriID, name: "Mori Ayane"),-                BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.studioID, name: "mori ayane 2", state: .merged,-                    canonicalID: BackupV11Fixtures.moriID),-                BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.aliasID, name: "mori ayane", state: .merged,-                    canonicalID: BackupV11Fixtures.studioID),+                BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.moriID, name: "Mori Ayane"),+                BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.studioID, name: "mori ayane 2", state: .merged,+                    canonicalID: BackupV12Fixtures.moriID),+                BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.aliasID, name: "mori ayane", state: .merged,+                    canonicalID: BackupV12Fixtures.studioID),             ])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: chain, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: chain, metadata: BackupV12Fixtures.metadata()))         } -        let roleChain = BackupV11Fixtures.creditsPayload(-            creatorRoles: BackupV11Fixtures.creatorRoleRecords-                + [BackupV11Fixtures.creatorRoleRecord(-                    id: BackupV11Fixtures.absentRoleID, name: "letters", position: 6,-                    state: .merged, canonicalID: BackupV11Fixtures.absentCreatorID)])+        let roleChain = BackupV12Fixtures.creditsPayload(+            creatorRoles: BackupV12Fixtures.creatorRoleRecords+                + [BackupV12Fixtures.creatorRoleRecord(+                    id: BackupV12Fixtures.absentRoleID, name: "letters", position: 6,+                    state: .merged, canonicalID: BackupV12Fixtures.absentCreatorID)])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: roleChain, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: roleChain, metadata: BackupV12Fixtures.metadata()))         }     } @@ -718,59 +838,59 @@ struct BackupV11CodecTests {     /// and no writer produces (Q65).     @Test("Two credits over one pair, and a repeated role identifier, refuse")     func malformedCreditsRefuse() throws {-        let pair = BackupV11Fixtures.creditsPayload(-            credits: BackupV11Fixtures.creditRecords-                + [BackupV11Fixtures.creditRecord(-                    id: BackupV11Fixtures.absentRoleID,-                    workID: BackupV11Fixtures.composedWorkID,-                    creatorID: BackupV11Fixtures.moriID,-                    roleIDs: [BackupV11Fixtures.artistRoleID])])+        let pair = BackupV12Fixtures.creditsPayload(+            credits: BackupV12Fixtures.creditRecords+                + [BackupV12Fixtures.creditRecord(+                    id: BackupV12Fixtures.absentRoleID,+                    workID: BackupV12Fixtures.composedWorkID,+                    creatorID: BackupV12Fixtures.moriID,+                    roleIDs: [BackupV12Fixtures.artistRoleID])])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: pair, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: pair, metadata: BackupV12Fixtures.metadata()))         } -        let repeated = BackupV11Fixtures.creditsPayload(+        let repeated = BackupV12Fixtures.creditsPayload(             credits: [-                BackupV11Credit(-                    id: BackupV11Fixtures.moriCreditID,-                    workID: BackupV11Fixtures.composedWorkID,-                    creatorID: BackupV11Fixtures.moriID,+                BackupV12Credit(+                    id: BackupV12Fixtures.moriCreditID,+                    workID: BackupV12Fixtures.composedWorkID,+                    creatorID: BackupV12Fixtures.moriID,                     roleIDs: [-                        BackupV11Fixtures.authorRoleID.uuidString,-                        BackupV11Fixtures.authorRoleID.uuidString,+                        BackupV12Fixtures.authorRoleID.uuidString,+                        BackupV12Fixtures.authorRoleID.uuidString,                     ],-                    createdAt: BackupV11Fixtures.created,-                    modifiedAt: BackupV11Fixtures.created)+                    createdAt: BackupV12Fixtures.created,+                    modifiedAt: BackupV12Fixtures.created)             ])         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(-                    payload: repeated, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(+                    payload: repeated, metadata: BackupV12Fixtures.metadata()))         }     }      @Test("A creator or role with an empty trimmed name refuses")     func emptyDirectoryNamesRefuse() throws {         for name in ["", "   ", "\n\t "] {-            let creators = BackupV11Fixtures.creditsPayload(-                creators: [BackupV11Fixtures.creatorRecord(-                    id: BackupV11Fixtures.moriID, name: name)],+            let creators = BackupV12Fixtures.creditsPayload(+                creators: [BackupV12Fixtures.creatorRecord(+                    id: BackupV12Fixtures.moriID, name: name)],                 credits: [])             #expect(throws: BackupCodecError.self) {-                try BackupV11Codec.decode(-                    try BackupV11Codec.encode(-                        payload: creators, metadata: BackupV11Fixtures.metadata()))+                try BackupV12Codec.decode(+                    try BackupV12Codec.encode(+                        payload: creators, metadata: BackupV12Fixtures.metadata()))             } -            let roles = BackupV11Fixtures.creditsPayload(-                creatorRoles: [BackupV11Fixtures.creatorRoleRecord(-                    id: BackupV11Fixtures.lettererRoleID, name: name, position: 3)],+            let roles = BackupV12Fixtures.creditsPayload(+                creatorRoles: [BackupV12Fixtures.creatorRoleRecord(+                    id: BackupV12Fixtures.lettererRoleID, name: name, position: 3)],                 credits: [])             #expect(throws: BackupCodecError.self) {-                try BackupV11Codec.decode(-                    try BackupV11Codec.encode(-                        payload: roles, metadata: BackupV11Fixtures.metadata()))+                try BackupV12Codec.decode(+                    try BackupV12Codec.encode(+                        payload: roles, metadata: BackupV12Fixtures.metadata()))             }         }     }@@ -782,21 +902,21 @@ struct BackupV11CodecTests {     @Test("A credit naming an absent work, creator and role is accepted")     func unresolvedCreditsValidate() throws {         let absentWork = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee98")!-        let payload = BackupV11Fixtures.creditsPayload(+        let payload = BackupV12Fixtures.creditsPayload(             credits: [-                BackupV11Fixtures.creditRecord(-                    id: BackupV11Fixtures.orphanCreditID, workID: absentWork,-                    creatorID: BackupV11Fixtures.absentCreatorID,-                    roleIDs: [BackupV11Fixtures.absentRoleID])+                BackupV12Fixtures.creditRecord(+                    id: BackupV12Fixtures.orphanCreditID, workID: absentWork,+                    creatorID: BackupV12Fixtures.absentCreatorID,+                    roleIDs: [BackupV12Fixtures.absentRoleID])             ]) -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          let credit = try #require(decoded.payload.credits.first)         #expect(credit.workID == absentWork)-        #expect(credit.creatorID == BackupV11Fixtures.absentCreatorID)-        #expect(credit.roleIDs == [BackupV11Fixtures.absentRoleID.uuidString])+        #expect(credit.creatorID == BackupV12Fixtures.absentCreatorID)+        #expect(credit.roleIDs == [BackupV12Fixtures.absentRoleID.uuidString])     }      // MARK: The citation arms@@ -807,11 +927,11 @@ struct BackupV11CodecTests {     /// refused, and it still refuses.     @Test("A composed identity with no name contributor refuses")     func composedIdentityWithoutANameContributorRefuses() throws {-        let payload = BackupV11Fixtures.composedPayload(dropNameContributor: true)+        let payload = BackupV12Fixtures.composedPayload(dropNameContributor: true)          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } @@ -819,9 +939,9 @@ struct BackupV11CodecTests {     /// `.rawURL` is a record that cannot say which key it holds.     @Test("A URL-rule basis carrying a raw-URL citation arm refuses")     func urlRuleBasisWithARawURLArmRefuses() throws {-        let base = BackupV11Fixtures.composedPayload()+        let base = BackupV12Fixtures.composedPayload()         let entry = try #require(base.entries.first)-        let stripped = BackupV11Entry(+        let stripped = BackupV12Entry(             id: entry.id, captureTitle: entry.captureTitle,             captureTitleSource: entry.captureTitleSource, rawURL: entry.rawURL,             canonicalURL: entry.canonicalURL, hostname: entry.hostname,@@ -833,53 +953,53 @@ struct BackupV11CodecTests {             lastSharedAt: entry.lastSharedAt, modifiedAt: entry.modifiedAt,             workID: entry.workID, intentionallyUnattached: entry.intentionallyUnattached,             citations: EntryCitations(identity: .rawURL))-        let payload = BackupV11Payload(+        let payload = BackupV12Payload(             entries: [stripped], works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: base.memberships)          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(-                try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+            try BackupV12Codec.decode(+                try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))         }     } -    @Test("A mismatched version pair around 11/12 is refused by the codec itself")+    @Test("A mismatched version pair around 12/13 is refused by the codec itself")     func mismatchedPairsRefuse() throws {-        let encoded = try BackupV11Codec.encode(-            payload: BackupV11Fixtures.payload(), metadata: BackupV11Fixtures.metadata())+        let encoded = try BackupV12Codec.encode(+            payload: BackupV12Fixtures.payload(), metadata: BackupV12Fixtures.metadata())         var object = try #require(             try JSONSerialization.jsonObject(with: encoded) as? [String: Any])         object["databaseSchemaVersion"] = 9          #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(try JSONSerialization.data(withJSONObject: object))+            try BackupV12Codec.decode(try JSONSerialization.data(withJSONObject: object))         }     } -    /// Req 8.2 through the codec: a 10/11 envelope is the pair this generation-    /// replaced, and it is refused at the door rather than half-decoded.-    @Test("A 10/11 envelope is refused by the codec")-    func eightNineEnvelopeRefuses() throws {+    /// Req 8.2 through the codec: an 11/12 envelope is the pair this generation+    /// replaced (Q51), and it is refused at the door rather than half-decoded.+    @Test("An 11/12 envelope is refused by the codec")+    func retiredEnvelopeRefuses() throws {         #expect(throws: BackupCodecError.self) {-            try BackupV11Codec.decode(BackupV11Fixtures.retiredGenerationDocument())+            try BackupV12Codec.decode(BackupV12Fixtures.retiredGenerationDocument())         }     }      /// A citation is the rule's UUID since 8/9, so `version` is a key the codec     /// does not write back — and the checksum is taken over the bytes as they-    /// arrived. A 11/12 file carrying one therefore fails the re-encode+    /// arrived. A 12/13 file carrying one therefore fails the re-encode     /// comparison, which is the same door every other unrepresentable key meets.     ///     /// The paired assertion is what makes this about the key rather than about     /// the paste: the identical literal with `"version":3` removed decodes.-    @Test("A 11/12 archive whose citation carries a version fails the checksum")+    @Test("A 12/13 archive whose citation carries a version fails the checksum")     func citationVersionFailsTheChecksum() throws {-        let document = BackupV11Fixtures.literalDocument(-            payload: BackupV11Fixtures.citationVersionPayloadJSON, entryCount: 1, workCount: 1)+        let document = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.citationVersionPayloadJSON, entryCount: 1, workCount: 1)          do {-            _ = try BackupV11Codec.decode(document)+            _ = try BackupV12Codec.decode(document)             Issue.record("expected a checksum refusal, but the document decoded")         } catch let error as BackupCodecError {             guard case .checksumMismatch = error else {@@ -888,16 +1008,16 @@ struct BackupV11CodecTests {             }         } -        let versionFree = BackupV11Fixtures.literalDocument(-            payload: BackupV11Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)-        let decoded = try BackupV11Codec.decode(versionFree)+        let versionFree = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)+        let decoded = try BackupV12Codec.decode(versionFree)         #expect(             decoded.payload.entries.first?.citations.chapterSequence                 == CitedRule(id: UUID(uuidString: "DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD")!))     }      /// Req 8.1 and Q34 from the wire side: the three status fields are declared-    /// without `decodeIfPresent` and without a default, so a 11/12 document whose+    /// without `decodeIfPresent` and without a default, so a 12/13 document whose     /// Work record omits them is malformed rather than restorable. A default     /// would put a reader's abandoned work back as one they are still reading.     ///@@ -905,13 +1025,13 @@ struct BackupV11CodecTests {     /// gives up on the missing key before the payload is ever re-encoded — and     /// the paired assertion pins it to the three keys rather than to the paste:     /// the identical literal carrying them decodes.-    @Test("A 11/12 Work record omitting the three status fields fails to decode")+    @Test("A 12/13 Work record omitting the three status fields fails to decode")     func workOmittingTheStatusFieldsRefuses() throws {-        let document = BackupV11Fixtures.literalDocument(-            payload: BackupV11Fixtures.statusFieldsOmittedPayloadJSON, entryCount: 1, workCount: 1)+        let document = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.statusFieldsOmittedPayloadJSON, entryCount: 1, workCount: 1)          do {-            _ = try BackupV11Codec.decode(document)+            _ = try BackupV12Codec.decode(document)             Issue.record("expected a decode refusal, but the document decoded")         } catch let error as BackupCodecError {             guard case .decodingFailed = error else {@@ -920,23 +1040,54 @@ struct BackupV11CodecTests {             }         } -        let complete = BackupV11Fixtures.literalDocument(-            payload: BackupV11Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)-        let record = try #require(try BackupV11Codec.decode(complete).payload.works.first)+        let complete = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)+        let record = try #require(try BackupV12Codec.decode(complete).payload.works.first)         #expect(record.workStatus == .ongoing)         #expect(record.readingStatus == .reading)         #expect(record.verdict.isEmpty)     } +    /// Req 5.1 and Q51 from the wire side. The two place arrays are declared+    /// like every other array — no `decodeIfPresent`, no default — so a payload+    /// shaped the way a **pre-feature** build wrote one is malformed here even+    /// when someone has relabelled its envelope 12/13. That is the same door the+    /// version refusal is, reached from the other side: an 11/12 file holds no+    /// place, and a default would restore a library asserting the reader had+    /// accepted none.+    @Test("A 12/13 payload without the two place arrays fails to decode")+    func payloadOmittingThePlaceArraysRefuses() throws {+        let document = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.placeArraysOmittedPayloadJSON, entryCount: 1, workCount: 1)++        do {+            _ = try BackupV12Codec.decode(document)+            Issue.record("expected a decode refusal, but the document decoded")+        } catch let error as BackupCodecError {+            guard case .decodingFailed = error else {+                Issue.record("expected .decodingFailed, got \(error)")+                return+            }+        }++        // And the identical literal carrying the two empty arrays decodes, so+        // the refusal is about them rather than about the paste.+        let complete = BackupV12Fixtures.literalDocument(+            payload: BackupV12Fixtures.citationVersionFreePayloadJSON, entryCount: 1, workCount: 1)+        let decoded = try BackupV12Codec.decode(complete)+        #expect(decoded.payload.places.isEmpty)+        #expect(decoded.payload.placeSuppressions.isEmpty)+    }+     /// Req 3.2: the version invariants are retired, so an archive whose Site     /// holds two title patterns at version 1 and a current URL rule below a     /// retired one is a file the reference checks accept.     @Test("An archive with duplicate and non-greatest rule versions decodes")     func duplicateAndNonGreatestVersionsDecode() throws {-        let payload = BackupV11Fixtures.duplicateVersionsPayload()+        let payload = BackupV12Fixtures.duplicateVersionsPayload() -        let decoded = try BackupV11Codec.decode(-            try BackupV11Codec.encode(payload: payload, metadata: BackupV11Fixtures.metadata()))+        let decoded = try BackupV12Codec.decode(+            try BackupV12Codec.encode(payload: payload, metadata: BackupV12Fixtures.metadata()))          #expect(decoded.payload == payload)         #expect(decoded.payload.titlePatterns.map(\.version) == [1, 1])@@ -946,33 +1097,38 @@ struct BackupV11CodecTests {  // MARK: - Export -@Suite("Backup V11 export", .serialized)-struct BackupV11ExportTests {+@Suite("Backup V12 export", .serialized)+struct BackupV12ExportTests {     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 roleID = UUID(uuidString: "60000000-0000-4000-8000-000000000005")!+    private static let placeID = UUID(uuidString: "60000000-0000-4000-8000-000000000006")!+    private static let orphanPlaceID = UUID(uuidString: "60000000-0000-4000-8000-000000000007")!+    /// The work id an orphan place names and no row answers for — the tolerated+    /// non-resolving owner of Req 5.5, which the archive has to carry verbatim.+    private static let absentWorkID = UUID(uuidString: "60000000-0000-4000-8000-0000000000e1")!     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 v11 filename and a valid, decodable 11/12 document")+    @Test("Exporter produces a v12 filename and a valid, decodable 12/13 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 = BackupV11Fixtures.payload()-        let exporter = BackupV11Exporter(-            repository: MockV11SnapshotProvider(payload: payload), stagingDirectory: tempDir)-        let result = try await exporter.export(metadata: BackupV11Fixtures.metadata())+        let payload = BackupV12Fixtures.payload()+        let exporter = BackupV12Exporter(+            repository: MockV12SnapshotProvider(payload: payload), stagingDirectory: tempDir)+        let result = try await exporter.export(metadata: BackupV12Fixtures.metadata()) -        #expect(result.fileURL.lastPathComponent.contains("v11"))-        let decoded = try BackupV11Codec.decode(try Data(contentsOf: result.fileURL))-        #expect(decoded.backupFormatVersion == 11)-        #expect(decoded.databaseSchemaVersion == 12)+        #expect(result.fileURL.lastPathComponent.contains("v12"))+        let decoded = try BackupV12Codec.decode(try Data(contentsOf: result.fileURL))+        #expect(decoded.backupFormatVersion == 12)+        #expect(decoded.databaseSchemaVersion == 13)         #expect(decoded.payload == payload)         exporter.cleanup(result)     }@@ -985,7 +1141,7 @@ struct BackupV11ExportTests {         store.insertCharacter(             id: Self.characterID, name: "Grover", aliases: ["Klar"], note: "The guide.",             facts: [-                CharacterFact(+                RecordFact(                     statement: "Promised to guide them home.",                     quote: "promised to guide them home", nameKey: "grover",                     source: .entry(Self.entryID))@@ -995,7 +1151,7 @@ struct BackupV11ExportTests {         store.coverGenericNotes()         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          let character = try #require(payload.characters.first)         #expect(character.id == Self.characterID)@@ -1041,7 +1197,7 @@ struct BackupV11ExportTests {         store.context.insert(twin)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          // One record for the pair, and it is the survivor's — carrying the         // address the discarded row held.@@ -1060,15 +1216,15 @@ struct BackupV11ExportTests {         store.insertCharacter(id: Self.orphanID, name: "Stranger", attachToWork: false)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(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 BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)     }      /// Req 6.5. One character UUID over two rows that disagree about something@@ -1081,8 +1237,8 @@ struct BackupV11ExportTests {         store.insertCharacter(id: Self.characterID, name: "Grover", note: "A traitor.")         try store.context.save() -        #expect(throws: BackupV11ExportError.self) {-            try LibraryRepository.projectV11Payload(context: store.context)+        #expect(throws: BackupV12ExportError.self) {+            try LibraryRepository.projectV12Payload(context: store.context)         }     } @@ -1095,18 +1251,94 @@ struct BackupV11ExportTests {         store.insertCharacter(             id: Self.characterID, name: "Grover",             facts: [-                CharacterFact(+                RecordFact(                     statement: "Was there.", quote: "was there", nameKey: "grover",                     source: .entry(absent))             ])         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.characters.first?.facts.first?.source == .entry(absent))-        let encoded = try BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)+    }++    // MARK: The places (Req 5.1, 5.3, 5.5)++    /// Req 5.1: what the store holds is what the archive carries, over the second+    /// table too — the place with its facts and the place-suppression row, both+    /// enumerated whole rather than through a work walk `Place` has no inverse+    /// for.+    @Test("Places and place suppressions project out of the store")+    func placesProject() throws {+        let store = try LibraryStore()+        store.insertPlace(+            id: Self.placeID, name: "The High Keep",+            facts: [+                RecordFact(+                    statement: "Sits above the pass.", quote: "above the pass",+                    nameKey: "high keep", source: .entry(Self.entryID))+            ])+        store.insertPlaceSuppression(nameKey: "low road")+        try store.context.save()++        let payload = try LibraryRepository.projectV12Payload(context: store.context)++        let place = try #require(payload.places.first)+        #expect(place.id == Self.placeID)+        #expect(place.workID == Self.workID)+        #expect(place.nameKey == "high keep")+        #expect(place.facts.map(\.quote) == ["above the pass"])++        let suppression = try #require(payload.placeSuppressions.first)+        #expect(suppression.nameKey == "low road")+        #expect(suppression.workID == Self.workID)+        #expect(suppression.kindRaw == CharacterSuppressionKind.candidate.rawValue)++        // The character arrays are untouched by any of it: the two kinds share a+        // shape, never a table.+        #expect(payload.characters.isEmpty)+        #expect(payload.suppressions.isEmpty)+    }++    /// Req 5.5 and Q67: a place whose `workID` resolves to nothing is reachable+    /// *only* by the whole-table enumeration, and it exports carrying the id it+    /// names rather than vanishing or being rewritten.+    @Test("A place whose work resolves to nothing exports with the id it names")+    func orphanPlaceExports() throws {+        let store = try LibraryStore()+        store.insertPlace(id: Self.orphanPlaceID, name: "Nowhere", workID: Self.absentWorkID)+        store.insertPlaceSuppression(nameKey: "elsewhere", workID: Self.absentWorkID)+        try store.context.save()++        let payload = try LibraryRepository.projectV12Payload(context: store.context)++        let orphan = try #require(payload.places.first { $0.id == Self.orphanPlaceID })+        #expect(orphan.workID == Self.absentWorkID)+        #expect(payload.placeSuppressions.first?.workID == Self.absentWorkID)+        // 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 BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)+    }++    /// Req 5.3: a torn place group is the same thing a torn character group is —+    /// one record with two authored values — so it refuses the export on exactly+    /// the same terms.+    @Test("A torn place group refuses the export")+    func tornPlaceRefusesExport() throws {+        let store = try LibraryStore()+        store.insertPlace(id: Self.placeID, name: "The High Keep", note: "Abandoned.")+        store.insertPlace(id: Self.placeID, name: "The High Keep", note: "Still garrisoned.")+        try store.context.save()++        #expect(throws: BackupV12ExportError.self) {+            try LibraryRepository.projectV12Payload(context: store.context)+        }     }      // MARK: Series and links (`series-and-related-works` Req 13.1, 13.2)@@ -1132,7 +1364,7 @@ struct BackupV11ExportTests {         store.insertLink(id: selfLink, a: Self.workID, b: Self.workID, type: "sequel")         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.links.map(\.id) == [newer])         #expect(payload.links.map(\.linkType) == ["sequel"])@@ -1151,7 +1383,7 @@ struct BackupV11ExportTests {         store.placeWork(seriesID: seriesID, position: 2.5)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.series.map(\.id) == [seriesID])         #expect(payload.series.first?.name == "Ashfall Cycle")@@ -1178,8 +1410,8 @@ struct BackupV11ExportTests {             store.placeWork(seriesID: id, position: position)             try store.context.save() -            let error = #expect(throws: BackupV11ExportError.self) {-                try LibraryRepository.projectV11Payload(context: store.context)+            let error = #expect(throws: BackupV12ExportError.self) {+                try LibraryRepository.projectV12Payload(context: store.context)             }             guard case .unrepresentableValue(let record, _, _) = error else {                 Issue.record("expected an unrepresentable-value refusal, got \(String(describing: error))")@@ -1216,7 +1448,7 @@ struct BackupV11ExportTests {         store.insertCreatorRole(id: Self.roleID, name: "letterer", position: 3)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.creators.count == 3)         let record = try #require(payload.creators.first { $0.id == mori })@@ -1234,9 +1466,9 @@ struct BackupV11ExportTests {          // And the file it produces is legal: the projection and the reference         // checks have to agree, or the export refuses its own bytes.-        let encoded = try BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)     }      /// Q68: a merged record whose survivor is not in the library reads as@@ -1253,14 +1485,14 @@ struct BackupV11ExportTests {             stateModifiedAt: Self.early)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          let record = try #require(payload.creators.first { $0.id == alias })         #expect(record.stateRaw == CreatorState.merged.rawValue)         #expect(record.canonicalID == nil)-        let encoded = try BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)     }      /// Req 9.2: one credit per work-and-creator pair, bucketed on the@@ -1291,7 +1523,7 @@ struct BackupV11ExportTests {             modifiedAt: Self.early.addingTimeInterval(120))         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.credits.count == 1)         let record = try #require(payload.credits.first)@@ -1339,7 +1571,7 @@ struct BackupV11ExportTests {             modifiedAt: Self.early.addingTimeInterval(120))         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          #expect(payload.creators.count == 2)         #expect(@@ -1363,9 +1595,9 @@ struct BackupV11ExportTests {         #expect(credit.roleIDs == [author.uuidString, artist.uuidString].sorted())          // The check that used to refuse this library's own archive.-        let encoded = try BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)     }      /// Req 10.2 at the export door: nothing is pruned for naming a work,@@ -1383,20 +1615,20 @@ struct BackupV11ExportTests {             createdAt: Self.early, modifiedAt: Self.early)         try store.context.save() -        let payload = try LibraryRepository.projectV11Payload(context: store.context)+        let payload = try LibraryRepository.projectV12Payload(context: store.context)          let record = try #require(payload.credits.first)         #expect(record.workID == absentWork)         #expect(record.creatorID == absentCreator)         #expect(record.roleIDs == [absentRole.uuidString])-        let encoded = try BackupV11Codec.encode(-            payload: payload, metadata: BackupV11Fixtures.metadata())-        #expect(try BackupV11Codec.decode(encoded).payload == payload)+        let encoded = try BackupV12Codec.encode(+            payload: payload, metadata: BackupV12Fixtures.metadata())+        #expect(try BackupV12Codec.decode(encoded).payload == payload)     }      // MARK: - Fixture -    /// An in-memory V11 store holding one taught-enough Site, one Work with+    /// An in-memory V13 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 {@@ -1404,7 +1636,7 @@ struct BackupV11ExportTests {         let context: ModelContext          init() throws {-            let schema = Schema(versionedSchema: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             container = try ModelContainer(                 for: schema,                 configurations: [@@ -1412,22 +1644,22 @@ struct BackupV11ExportTests {                         schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)                 ])             context = ModelContext(container)-            let site = Site(hostname: BackupV11ExportTests.host, displayName: "Characters")+            let site = Site(hostname: BackupV12ExportTests.host, displayName: "Characters")             site.mode = .untaught             context.insert(site)              let work = Work.create(-                in: context, id: BackupV11ExportTests.workID, title: "A Work",-                hostname: BackupV11ExportTests.host, site: site,-                timestamp: BackupV11ExportTests.early)-            work.genericNotes = BackupV11ExportTests.genericNotes+                in: context, id: BackupV12ExportTests.workID, title: "A Work",+                hostname: BackupV12ExportTests.host, site: site,+                timestamp: BackupV12ExportTests.early)+            work.genericNotes = BackupV12ExportTests.genericNotes -            let rawURL = "https://\(BackupV11ExportTests.host)/read/1"+            let rawURL = "https://\(BackupV12ExportTests.host)/read/1"             let entry = Entry(-                id: BackupV11ExportTests.entryID, captureTitle: "Chapter 1",+                id: BackupV12ExportTests.entryID, captureTitle: "Chapter 1",                 captureTitleSource: .host, rawURLString: rawURL,-                hostname: BackupV11ExportTests.host, entryIdentityKey: rawURL,-                timestamp: BackupV11ExportTests.early, note: BackupV11ExportTests.note)+                hostname: BackupV12ExportTests.host, entryIdentityKey: rawURL,+                timestamp: BackupV12ExportTests.early, note: BackupV12ExportTests.note)             entry.conservativeIdentityKey = rawURL             entry.editCitations { $0.workAssignment = .manual }             context.insert(entry)@@ -1441,40 +1673,63 @@ struct BackupV11ExportTests {          func insertCharacter(             id: UUID, name: String, aliases: [String] = [], note: String = "",-            facts: [CharacterFact] = [], attachToWork: Bool = true+            facts: [RecordFact] = [], attachToWork: Bool = true         ) {             let character = CharacterRecord(-                id: id, name: name, nameKey: CharacterNameKey.normalize(name),+                id: id, name: name, nameKey: RecordNameKey.normalize(name),                 aliases: aliases, note: note, facts: facts,-                timestamp: BackupV11ExportTests.early)+                timestamp: BackupV12ExportTests.early)             context.insert(character)             if attachToWork { character.work = work }         }          func insertSuppression(nameKey: String) {             let row = CharacterSuppression(-                kind: .candidate, nameKey: nameKey, actionAt: BackupV11ExportTests.early)+                kind: .candidate, nameKey: nameKey, actionAt: BackupV12ExportTests.early)             context.insert(row)             row.work = work         } +        /// The place twin. Ownership is a **column**, so the caller names the+        /// work id rather than handing over a row — which is the only way to+        /// seed the non-resolving owner of Req 5.5.+        func insertPlace(+            id: UUID, name: String, aliases: [String] = [], note: String = "",+            facts: [RecordFact] = [], workID: UUID = BackupV12ExportTests.workID+        ) {+            context.insert(+                Place(+                    id: id, name: name, nameKey: RecordNameKey.normalize(name),+                    aliases: aliases, note: note, facts: facts,+                    timestamp: BackupV12ExportTests.early, workID: workID))+        }++        func insertPlaceSuppression(+            nameKey: String, workID: UUID = BackupV12ExportTests.workID+        ) {+            context.insert(+                PlaceSuppression(+                    workID: workID, kind: .candidate, nameKey: nameKey,+                    actionAt: BackupV12ExportTests.early))+        }+         func coverEntry() {             try? context.fetch(FetchDescriptor<Entry>()).first?                 .characterExtractionFingerprint = CharacterCoverageFingerprint.of(-                    BackupV11ExportTests.note)+                    BackupV12ExportTests.note)         }          func coverGenericNotes() {             work?.genericNotesExtractionFingerprint = CharacterCoverageFingerprint.of(-                BackupV11ExportTests.genericNotes)+                BackupV12ExportTests.genericNotes)         }          // `series-and-related-works` Req 13.          func insertSeries(             id: UUID, name: String, notes: String = "",-            createdAt: Date = BackupV11ExportTests.early,-            modifiedAt: Date = BackupV11ExportTests.early+            createdAt: Date = BackupV12ExportTests.early,+            modifiedAt: Date = BackupV12ExportTests.early         ) {             context.insert(                 Series(@@ -1486,13 +1741,13 @@ struct BackupV11ExportTests {         /// self-link no writer produces and the projection drops.         func insertLink(             id: UUID, a: UUID, b: UUID, type: String,-            modifiedAt: Date = BackupV11ExportTests.early+            modifiedAt: Date = BackupV12ExportTests.early         ) {             let sorted = WorkDistinctPair.sortedIDs(a, b)             context.insert(                 WorkLink(                     id: id, lowerWorkID: sorted.lower, higherWorkID: sorted.higher,-                    linkType: type, createdAt: BackupV11ExportTests.early,+                    linkType: type, createdAt: BackupV12ExportTests.early,                     modifiedAt: modifiedAt))         } @@ -1516,7 +1771,7 @@ struct BackupV11ExportTests {             let row = Creator(                 id: id, name: name, notes: notes, stateRaw: state.rawValue,                 canonicalID: canonicalID)-            row.createdAt = BackupV11ExportTests.early+            row.createdAt = BackupV12ExportTests.early             row.nameModifiedAt = nameModifiedAt             row.notesModifiedAt = notesModifiedAt             row.stateModifiedAt = stateModifiedAt@@ -1527,14 +1782,14 @@ struct BackupV11ExportTests {         func insertCreatorRole(             id: UUID, name: String, position: Int, state: CreatorRoleState = .active,             canonicalID: UUID? = nil,-            nameModifiedAt: Date = BackupV11ExportTests.early,-            positionModifiedAt: Date = BackupV11ExportTests.early,-            stateModifiedAt: Date = BackupV11ExportTests.early+            nameModifiedAt: Date = BackupV12ExportTests.early,+            positionModifiedAt: Date = BackupV12ExportTests.early,+            stateModifiedAt: Date = BackupV12ExportTests.early         ) {             let row = CreatorRole(                 id: id, name: name, position: position, stateRaw: state.rawValue,                 canonicalID: canonicalID)-            row.createdAt = BackupV11ExportTests.early+            row.createdAt = BackupV12ExportTests.early             row.nameModifiedAt = nameModifiedAt             row.positionModifiedAt = positionModifiedAt             row.stateModifiedAt = stateModifiedAt@@ -1557,20 +1812,20 @@ struct BackupV11ExportTests {  // MARK: - Import -@Suite("Backup 11/12 import", .serialized)-struct BackupV11ImportTests {+@Suite("Backup 12/13 import", .serialized)+struct BackupV12ImportTests {      // MARK: One accepted pair (Decision 2) -    @Test("The importer accepts 11/12")+    @Test("The importer accepts 12/13")     func acceptedGeneration() throws {-        let data = try BackupV11Codec.encode(-            payload: BackupV11Fixtures.payload(), metadata: BackupV11Fixtures.metadata())+        let data = try BackupV12Codec.encode(+            payload: BackupV12Fixtures.payload(), metadata: BackupV12Fixtures.metadata())          let plan = try BackupImporter.plan(from: data)-        #expect(plan.metadata.formatVersion == 11)-        #expect(plan.metadata.schemaVersion == 12)-        #expect(plan.payload == BackupImportPayload(BackupV11Fixtures.payload()))+        #expect(plan.metadata.formatVersion == 12)+        #expect(plan.metadata.schemaVersion == 13)+        #expect(plan.payload == BackupImportPayload(BackupV12Fixtures.payload()))     }      /// The retired generations refuse **by version**, and the refusal names the@@ -1582,13 +1837,15 @@ struct BackupV11ImportTests {     /// tell the reader their backup is corrupt. It is not corrupt; it is old,     /// and the message has to say so (Req 13.1, Q13).     ///-    /// (9, 10) leads the list: it is the generation this one replaced, and the-    /// one a reader upgrading across T-2308 is holding.+    /// (11, 12) leads the list: it is the generation this one replaced (Q51),+    /// and the one a reader upgrading across T-2276 is holding.     @Test(         "A retired generation refuses by version, naming the pair",-        arguments: [(10, 11), (9, 10), (8, 9), (7, 8), (6, 7), (4, 4), (5, 6), (3, 3)])+        arguments: [+            (11, 12), (10, 11), (9, 10), (8, 9), (7, 8), (6, 7), (4, 4), (5, 6), (3, 3),+        ])     func retiredGenerationsRefuseByVersion(pair: (format: Int, schema: Int)) throws {-        let data = BackupV11Fixtures.retiredGenerationDocument(+        let data = BackupV12Fixtures.retiredGenerationDocument(             format: pair.format, schema: pair.schema)         // The envelope is intact — this is a version refusal, not a decode one.         #expect((try? JSONSerialization.jsonObject(with: data)) != nil)@@ -1603,12 +1860,12 @@ struct BackupV11ImportTests {         #expect(reason.contains("format \(pair.format)"))         #expect(reason.contains("schema \(pair.schema)"))         // Req 13.1 names both pairs: the archive's and the one this build reads.-        #expect(reason.contains("(\(BackupV11Document.formatVersion)/\(BackupV11Document.schemaVersion))"))+        #expect(reason.contains("(\(BackupV12Document.formatVersion)/\(BackupV12Document.schemaVersion))"))     } -    @Test("A mismatched pair around 11/12 is unsupported")+    @Test("A mismatched pair around 12/13 is unsupported")     func mismatchedPairsReject() throws {-        for (format, schema) in [(11, 11), (11, 13), (10, 12), (12, 12)] {+        for (format, schema) in [(12, 12), (12, 14), (11, 13), (13, 13)] {             let data = try JSONSerialization.data(withJSONObject: [                 "backupFormatVersion": format,                 "databaseSchemaVersion": schema,@@ -1621,40 +1878,40 @@ struct BackupV11ImportTests {      // MARK: What lands (Req 6.1) -    @Test("A 11/12 archive commits its characters, suppressions and coverage")+    @Test("A 12/13 archive commits its characters, suppressions and coverage")     func archiveCommits() async throws {         let fixture = try await M5Fixture()          let result = try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.payload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.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 == BackupV11Fixtures.groverID })+        let grover = try #require(characters.first { $0.id == BackupV12Fixtures.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(BackupV11Fixtures.entryID))-        #expect(grover.workID == BackupV11Fixtures.workID, "the character joins its work")+        #expect(grover.facts.first?.source == .entry(BackupV12Fixtures.entryID))+        #expect(grover.workID == BackupV12Fixtures.workID, "the character joins its work")          let suppressions = try await fixture.repository.m5SuppressionRows()-        let row = try #require(suppressions.first { $0.id == BackupV11Fixtures.suppressionID })+        let row = try #require(suppressions.first { $0.id == BackupV12Fixtures.suppressionID })         #expect(row.nameKey == "the crowned one")         #expect(row.kind == .candidate)         #expect(row.status == .active)-        #expect(row.workID == BackupV11Fixtures.workID)+        #expect(row.workID == BackupV12Fixtures.workID)          #expect(-            try await fixture.repository.m5EntryCoverage(BackupV11Fixtures.entryID)-                == BackupV11Fixtures.noteFingerprint)+            try await fixture.repository.m5EntryCoverage(BackupV12Fixtures.entryID)+                == BackupV12Fixtures.noteFingerprint)         #expect(-            try await fixture.repository.m5WorkCoverage(BackupV11Fixtures.workID)-                == BackupV11Fixtures.genericNotesFingerprint)+            try await fixture.repository.m5WorkCoverage(BackupV12Fixtures.workID)+                == BackupV12Fixtures.genericNotesFingerprint)     }      /// Req 6.7 through the archive: a character with no work is a tolerated@@ -1664,14 +1921,14 @@ struct BackupV11ImportTests {         let fixture = try await M5Fixture()          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(                     characters: [-                        BackupV11Fixtures.character(id: BackupV11Fixtures.orphanID, workID: nil)+                        BackupV12Fixtures.character(id: BackupV12Fixtures.orphanID, workID: nil)                     ])))          let characters = try await fixture.repository.m5AllCharacters()-        let orphan = try #require(characters.first { $0.id == BackupV11Fixtures.orphanID })+        let orphan = try #require(characters.first { $0.id == BackupV12Fixtures.orphanID })         #expect(orphan.workID == nil)     } @@ -1683,13 +1940,13 @@ struct BackupV11ImportTests {         let fixture = try await M5Fixture()          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(entryFingerprint: "not-this-note")))+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(entryFingerprint: "not-this-note"))) -        #expect(try await fixture.repository.m5EntryCoverage(BackupV11Fixtures.entryID) == nil)+        #expect(try await fixture.repository.m5EntryCoverage(BackupV12Fixtures.entryID) == nil)         #expect(-            try await fixture.repository.m5WorkCoverage(BackupV11Fixtures.workID)-                == BackupV11Fixtures.genericNotesFingerprint)+            try await fixture.repository.m5WorkCoverage(BackupV12Fixtures.workID)+                == BackupV12Fixtures.genericNotesFingerprint)     }      // MARK: Value guards and idempotence (Req 6.1, 7.7's shape)@@ -1698,24 +1955,25 @@ struct BackupV11ImportTests {     /// rows: the memberships and the pairs are asserted beside the characters and     /// suppressions, because they are the two the 7/8 format added and the two a     /// second import could silently rewrite.-    @Test("Importing the same 11/12 archive twice changes nothing the second time")+    @Test("Importing the same 12/13 archive twice changes nothing the second time")     func importingTwiceChangesNothing() async throws {         let fixture = try await M5Fixture()-        let base = BackupV11Fixtures.payload()+        let base = BackupV12Fixtures.payload()         let stranger = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee99")!-        let ids = WorkDistinctPair.sortedIDs(BackupV11Fixtures.workID, stranger)-        let plan = BackupV11Fixtures.plan(-            BackupV11Payload(+        let ids = WorkDistinctPair.sortedIDs(BackupV12Fixtures.workID, stranger)+        let plan = BackupV12Fixtures.plan(+            BackupV12Payload(                 entries: base.entries, works: base.works, sites: base.sites,                 titlePatterns: base.titlePatterns, urlRules: base.urlRules,                 workTypes: base.workTypes, memberships: base.memberships,                 distinctPairs: [-                    BackupV11DistinctPair(+                    BackupV12DistinctPair(                         id: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee98")!,                         lowerWorkID: ids.lower, higherWorkID: ids.higher,-                        recordedAt: BackupV11Fixtures.created)+                        recordedAt: BackupV12Fixtures.created)                 ],-                characters: base.characters, suppressions: base.suppressions))+                characters: base.characters, suppressions: base.suppressions,+                places: base.places, placeSuppressions: base.placeSuppressions))          try await fixture.repository.confirmImport(plan: plan)         let charactersAfterFirst = try await fixture.repository.m5AllCharacters()@@ -1737,20 +1995,20 @@ struct BackupV11ImportTests {     func olderArchiveDoesNotRegressACharacter() async throws {         let fixture = try await M5Fixture()         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.payload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload()))          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(                     characters: [-                        BackupV11Fixtures.character(+                        BackupV12Fixtures.character(                             name: "Renamed by an older device", note: "older",-                            modifiedAt: BackupV11Fixtures.created.addingTimeInterval(-1_000))+                            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(-1_000))                     ])))          let grover = try #require(             try await fixture.repository.m5AllCharacters()-                .first { $0.id == BackupV11Fixtures.groverID })+                .first { $0.id == BackupV12Fixtures.groverID })         #expect(grover.name == "Grover")         #expect(grover.note == "The guide.")     }@@ -1759,20 +2017,20 @@ struct BackupV11ImportTests {     func newerArchiveUpdatesACharacter() async throws {         let fixture = try await M5Fixture()         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.payload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload()))          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(                     characters: [-                        BackupV11Fixtures.character(+                        BackupV12Fixtures.character(                             name: "Grover Underwood", note: "Still the guide.",-                            modifiedAt: BackupV11Fixtures.created.addingTimeInterval(1_000))+                            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(1_000))                     ])))          let grover = try #require(             try await fixture.repository.m5AllCharacters()-                .first { $0.id == BackupV11Fixtures.groverID })+                .first { $0.id == BackupV12Fixtures.groverID })         #expect(grover.name == "Grover Underwood")         #expect(grover.note == "Still the guide.")         // The retained key never moves with a rename (Q19/Q46) — including a@@ -1786,21 +2044,21 @@ struct BackupV11ImportTests {     func olderSuppressionDoesNotUndoAClear() async throws {         let fixture = try await M5Fixture()         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(                     suppressions: [-                        BackupV11Fixtures.suppression(+                        BackupV12Fixtures.suppression(                             status: .cleared,-                            actionAt: BackupV11Fixtures.created.addingTimeInterval(1_000))+                            actionAt: BackupV12Fixtures.created.addingTimeInterval(1_000))                     ])))          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.payload(suppressions: [BackupV11Fixtures.suppression()])))+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(suppressions: [BackupV12Fixtures.suppression()])))          let row = try #require(             try await fixture.repository.m5SuppressionRows()-                .first { $0.id == BackupV11Fixtures.suppressionID })+                .first { $0.id == BackupV12Fixtures.suppressionID })         #expect(row.status == .cleared)     } @@ -1812,21 +2070,271 @@ struct BackupV11ImportTests {     ///     /// It was parameterised over the generations that had nowhere to write a     /// character. Those read paths are gone (Decision 2), so the state is now-    /// reached the only way it still can be — a 11/12 archive whose arrays are+    /// reached the only way it still can be — a 12/13 archive whose arrays are     /// empty.     @Test("Importing an archive with no characters creates none")     func archivesWithoutCharactersCreateNone() async throws {         let fixture = try await M5Fixture()          let plan = try BackupImporter.plan(-            from: try BackupV11Codec.encode(-                payload: BackupV11Fixtures.composedPayload(),-                metadata: BackupV11Fixtures.metadata()))+            from: try BackupV12Codec.encode(+                payload: BackupV12Fixtures.composedPayload(),+                metadata: BackupV12Fixtures.metadata()))         try await fixture.repository.confirmImport(plan: plan)          #expect(try await fixture.repository.m5AllCharacters().isEmpty)         #expect(try await fixture.repository.m5SuppressionRows().isEmpty)-        #expect(try await fixture.repository.m5EntryCoverage(BackupV11Fixtures.entryID) == nil)+        #expect(try await fixture.repository.m5AllPlaces().isEmpty)+        #expect(try await fixture.repository.m5PlaceSuppressionRows().isEmpty)+        #expect(try await fixture.repository.m5EntryCoverage(BackupV12Fixtures.entryID) == nil)+    }++    // MARK: The places (Req 5.1, 5.3, 5.5)++    /// Req 5.1: a 12/13 archive lands its places and their suppressions into a+    /// library that holds only the seeds, on the same terms the characters+    /// arrive on — the generic merge is one body called once per kind, and this+    /// is the place call's evidence that it ran.+    @Test("A 12/13 archive commits its places and place suppressions")+    func archiveCommitsPlaces() async throws {+        let fixture = try await M5Fixture()++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload()))++        let places = try await fixture.repository.m5AllPlaces()+        let keep = try #require(places.first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.name == "The High Keep")+        #expect(keep.nameKey == "high keep")+        #expect(keep.aliases == ["The Keep"])+        #expect(keep.note == "The fortress above the pass.")+        #expect(keep.facts.map(\.quote) == ["above the pass"])+        #expect(keep.workID == BackupV12Fixtures.workID, "the place joins its work")++        let rows = try await fixture.repository.m5PlaceSuppressionRows()+        let row = try #require(rows.first { $0.id == BackupV12Fixtures.placeSuppressionID })+        #expect(row.nameKey == "low road")+        #expect(row.kind == .candidate)+        #expect(row.status == .active)+        #expect(row.workID == BackupV12Fixtures.workID)+    }++    /// Req 5.5 and Q60: an owner the archive names and this library cannot+    /// resolve is **kept**, not dropped and not rewritten — and the row+    /// re-exports naming the same work, so a restore of a half-synced library+    /// loses nothing.+    @Test("A place whose work resolves to nothing imports and re-exports as it stands")+    func orphanPlaceImportsAndReExports() async throws {+        let absent = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee97")!+        let fixture = try await M5Fixture()++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    places: [+                        BackupV12Fixtures.place(+                            id: BackupV12Fixtures.orphanPlaceID, workID: absent)+                    ],+                    placeSuppressions: [BackupV12Fixtures.placeSuppression(workID: absent)])))++        let orphan = try #require(+            try await fixture.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.orphanPlaceID })+        #expect(orphan.workID == absent)+        #expect(+            try await fixture.repository.m5PlaceSuppressionRows()+                .first { $0.id == BackupV12Fixtures.placeSuppressionID }?.workID == absent)++        let payload = try await fixture.repository.backupV12Snapshot()+        #expect(payload.places.first { $0.id == BackupV12Fixtures.orphanPlaceID }?.workID == absent)+        #expect(payload.placeSuppressions.contains { $0.workID == absent })+    }++    /// Req 5.5 on the **update** path, which is the other side of the orphan the+    /// test above admits: the archive holds place P naming a work this library+    /// does not have, while the reader's own P sits on a work it does.+    ///+    /// Ownership carries no timestamp — `workID` moves without touching+    /// `modifiedAt` — so the merge's `modifiedAt >=` guard cannot see the move,+    /// and writing the archived id would take P off its work and leave it+    /// displayed nowhere. The archive's *content* still applies; only the owner+    /// it names and this library cannot resolve is refused.+    @Test("An archived orphan place does not move a local place off the work it has")+    func archivedOrphanDoesNotMoveALocalPlace() async throws {+        let localWorkID = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee96")!+        let absent = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee97")!+        let older = BackupV12Fixtures.created.addingTimeInterval(-1_000)+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "b.example")],+            works: [+                M5SeedWork(+                    id: localWorkID, displayTitle: "The Reader's Own", hostname: "b.example")+            ],+            places: [+                M5SeedPlace(+                    id: BackupV12Fixtures.keepID, name: "The Keep", nameKey: "high keep",+                    workID: localWorkID, createdAt: older, modifiedAt: older)+            ])++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(places: [BackupV12Fixtures.place(workID: absent)])))++        let keep = try #require(+            try await fixture.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.workID == localWorkID, "an owner that resolves to nothing never moves a place")+        #expect(keep.name == "The High Keep", "the archive's content still applies")+    }++    /// The half the narrowing keeps: a local place whose own `workID` resolves+    /// to nothing has no owner to lose, so it **adopts** the archived one — which+    /// is how a row that synced ahead of its work heals from a restore.+    @Test("A local orphan place adopts an archived work this library resolves")+    func localOrphanPlaceAdoptsAResolvingWork() async throws {+        let unresolvable = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee95")!+        let older = BackupV12Fixtures.created.addingTimeInterval(-1_000)+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            places: [+                M5SeedPlace(+                    id: BackupV12Fixtures.keepID, name: "The Keep", nameKey: "high keep",+                    workID: unresolvable, createdAt: older, modifiedAt: older)+            ])++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload()))++        let keep = try #require(+            try await fixture.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.workID == BackupV12Fixtures.workID)+        #expect(keep.name == "The High Keep")+    }++    /// The suppression twin of the two above (Q76). A suppression's owner has no+    /// timestamp either — `actionAt` moves with the reader's action, not with+    /// ownership — so an archived row naming a work this library cannot resolve+    /// would take a local suppression off the work it is on, and a suppression+    /// displaced from its work suppresses nothing: the name the reader refused+    /// comes back at the next pass.+    @Test("An archived orphan place suppression does not move a local one off its work")+    func archivedOrphanDoesNotMoveAPlaceSuppression() async throws {+        let localWorkID = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee94")!+        let absent = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee97")!+        let older = BackupV12Fixtures.created.addingTimeInterval(-1_000)+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "b.example")],+            works: [+                M5SeedWork(+                    id: localWorkID, displayTitle: "The Reader's Own", hostname: "b.example")+            ],+            placeSuppressions: [+                M5SeedPlaceSuppression(+                    id: BackupV12Fixtures.placeSuppressionID, workID: localWorkID,+                    nameKey: "old key", actionAt: older)+            ])++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    placeSuppressions: [BackupV12Fixtures.placeSuppression(workID: absent)])))++        let row = try #require(+            try await fixture.repository.m5PlaceSuppressionRows()+                .first { $0.id == BackupV12Fixtures.placeSuppressionID })+        #expect(+            row.workID == localWorkID,+            "an owner that resolves to nothing never moves a suppression")+        #expect(row.nameKey == "low road", "the archive's content still applies")+    }++    /// The idempotence half, over the two arrays this generation adds: a second+    /// import of the same file writes nothing.+    @Test("Importing the same 12/13 archive twice leaves the places untouched")+    func importingTwiceLeavesPlacesUntouched() async throws {+        let fixture = try await M5Fixture()+        let plan = BackupV12Fixtures.plan(BackupV12Fixtures.payload())++        try await fixture.repository.confirmImport(plan: plan)+        let placesAfterFirst = try await fixture.repository.m5AllPlaces()+        let suppressionsAfterFirst = try await fixture.repository.m5PlaceSuppressionRows()++        try await fixture.repository.confirmImport(plan: plan)++        #expect(try await fixture.repository.m5AllPlaces() == placesAfterFirst)+        #expect(try await fixture.repository.m5PlaceSuppressionRows() == suppressionsAfterFirst)+        #expect(placesAfterFirst.count == 1)+        #expect(suppressionsAfterFirst.count == 1)+    }++    /// Req 5.1's value guard: `modifiedAt` decides, both ways, so an older+    /// archive cannot regress a newer edit and a newer one applies.+    @Test("The place guard is modifiedAt, in both directions")+    func placeModificationGuard() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload()))++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    places: [+                        BackupV12Fixtures.place(+                            name: "Renamed by an older device", note: "older",+                            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(-1_000))+                    ])))++        var keep = try #require(+            try await fixture.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.name == "The High Keep")+        #expect(keep.note == "The fortress above the pass.")++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    places: [+                        BackupV12Fixtures.place(+                            name: "The Keep Above the Pass", note: "Still standing.",+                            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(1_000))+                    ])))++        keep = try #require(+            try await fixture.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.name == "The Keep Above the Pass")+        #expect(keep.note == "Still standing.")+        // The retained key never moves with a rename, an archived one included.+        #expect(keep.nameKey == "high keep")+    }++    /// Req 5.1's other guard: a place suppression converges on `actionAt`, so an+    /// archived row cannot undo a newer clear — the character rule, applied to+    /// the table that keeps it separate (Decision 2).+    @Test("A place suppression older than the stored row does not undo a clear")+    func olderPlaceSuppressionDoesNotUndoAClear() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    placeSuppressions: [+                        BackupV12Fixtures.placeSuppression(+                            status: .cleared,+                            actionAt: BackupV12Fixtures.created.addingTimeInterval(1_000))+                    ])))++        try await fixture.repository.confirmImport(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.payload(+                    placeSuppressions: [BackupV12Fixtures.placeSuppression()])))++        let row = try #require(+            try await fixture.repository.m5PlaceSuppressionRows()+                .first { $0.id == BackupV12Fixtures.placeSuppressionID })+        #expect(row.status == .cleared)     }      // MARK: Series and links (`series-and-related-works` Req 13.3, 13.4)@@ -1836,22 +2344,22 @@ struct BackupV11ImportTests {     @Test("An import into an empty library reproduces series, memberships and links")     func seriesAndLinksImportWhole() async throws {         let fixture = try await M5Fixture()-        let plan = BackupV11Fixtures.plan(BackupV11Fixtures.seriesPayload())+        let plan = BackupV12Fixtures.plan(BackupV12Fixtures.seriesPayload())          try await fixture.repository.confirmImport(plan: plan)          let series = try await fixture.repository.seriesRowValues()-        #expect(series.map(\.id) == [BackupV11Fixtures.seriesID])+        #expect(series.map(\.id) == [BackupV12Fixtures.seriesID])         #expect(series.first?.name == "Ashfall Cycle")         #expect(series.first?.notes == "Read 2.5 after 2.")         #expect(-            try await fixture.repository.membershipColumns(of: BackupV11Fixtures.composedWorkID)-                == [SeriesColumns(seriesID: BackupV11Fixtures.seriesID, position: 1)])+            try await fixture.repository.membershipColumns(of: BackupV12Fixtures.composedWorkID)+                == [SeriesColumns(seriesID: BackupV12Fixtures.seriesID, position: 1)])         #expect(-            try await fixture.repository.membershipColumns(of: BackupV11Fixtures.secondWorkID)-                == [SeriesColumns(seriesID: BackupV11Fixtures.seriesID, position: 2.5)])+            try await fixture.repository.membershipColumns(of: BackupV12Fixtures.secondWorkID)+                == [SeriesColumns(seriesID: BackupV12Fixtures.seriesID, position: 2.5)])         let links = try await fixture.repository.workLinkRowValues()-        #expect(links.map(\.id) == [BackupV11Fixtures.linkID])+        #expect(links.map(\.id) == [BackupV12Fixtures.linkID])         #expect(links.first?.linkType == "adaptation")          // A repeated import writes the same values back and removes nothing.@@ -1870,29 +2378,29 @@ struct BackupV11ImportTests {         let local = UUID(uuidString: "5E81E5A0-0000-4000-8000-0000000000ff")!         let localLink = UUID(uuidString: "11115E51-0000-4000-8000-0000000000ff")!         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.seriesPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.seriesPayload()))         // Two rows only this library holds, and a newer local edit to the         // series the archive also carries.         try await fixture.repository.seedSeries([SeedSeries(id: local, name: "Quiet Shelf")])         try await fixture.repository.seedWorkLinks([             SeedWorkLink(-                id: localLink, a: BackupV11Fixtures.composedWorkID,+                id: localLink, a: BackupV12Fixtures.composedWorkID,                 b: UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee97")!, type: "prequel")         ])         try await fixture.repository.updateSeries(-            id: BackupV11Fixtures.seriesID, name: "Renamed here", notes: "later")+            id: BackupV12Fixtures.seriesID, name: "Renamed here", notes: "later")         try await fixture.repository.retypeLink(-            id: BackupV11Fixtures.linkID, type: "retyped here")+            id: BackupV12Fixtures.linkID, type: "retyped here")          // The same archive again: its records are now older than both rows.         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.seriesPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.seriesPayload()))          let afterOlder = try await fixture.repository.seriesRowValues()-        #expect(afterOlder.first { $0.id == BackupV11Fixtures.seriesID }?.name == "Renamed here")+        #expect(afterOlder.first { $0.id == BackupV12Fixtures.seriesID }?.name == "Renamed here")         #expect(             try await fixture.repository.workLinkRowValues()-                .first { $0.id == BackupV11Fixtures.linkID }?.linkType == "retyped here")+                .first { $0.id == BackupV12Fixtures.linkID }?.linkType == "retyped here")         // Nothing the archive does not carry was removed.         #expect(afterOlder.contains { $0.id == local })         #expect(try await fixture.repository.workLinkIDs().contains(localLink))@@ -1902,19 +2410,19 @@ struct BackupV11ImportTests {         // own epoch — not merely later than the archive's own `created`.         let later = M5Fixture.epoch.addingTimeInterval(3_600)         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.seriesPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.seriesPayload(                     series: [-                        BackupV11Fixtures.seriesRecord(name: "Renamed there", modifiedAt: later)+                        BackupV12Fixtures.seriesRecord(name: "Renamed there", modifiedAt: later)                     ],-                    links: [BackupV11Fixtures.linkRecord(type: "retyped there", modifiedAt: later)])))+                    links: [BackupV12Fixtures.linkRecord(type: "retyped there", modifiedAt: later)])))          #expect(             try await fixture.repository.seriesRowValues()-                .first { $0.id == BackupV11Fixtures.seriesID }?.name == "Renamed there")+                .first { $0.id == BackupV12Fixtures.seriesID }?.name == "Renamed there")         #expect(             try await fixture.repository.workLinkRowValues()-                .first { $0.id == BackupV11Fixtures.linkID }?.linkType == "retyped there")+                .first { $0.id == BackupV12Fixtures.linkID }?.linkType == "retyped there")     }      /// Req 13.5's tolerated half, at the store rather than on the wire: a work@@ -1927,16 +2435,16 @@ struct BackupV11ImportTests {         let absentSeries = UUID(uuidString: "5E81E5A0-0000-4000-8000-000000000009")!         let absentWork = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeee99")!         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.seriesPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.seriesPayload(                     links: [-                        BackupV11Fixtures.linkRecord(-                            a: BackupV11Fixtures.composedWorkID, b: absentWork, type: "spin-off")+                        BackupV12Fixtures.linkRecord(+                            a: BackupV12Fixtures.composedWorkID, b: absentWork, type: "spin-off")                     ],                     firstMembership: (absentSeries, 3))))          #expect(-            try await fixture.repository.membershipColumns(of: BackupV11Fixtures.composedWorkID)+            try await fixture.repository.membershipColumns(of: BackupV12Fixtures.composedWorkID)                 == [SeriesColumns(seriesID: absentSeries, position: 3)])         let links = try await fixture.repository.workLinkRowValues()         #expect(links.count == 1)@@ -1953,47 +2461,47 @@ struct BackupV11ImportTests {     @Test("An import into a seeds-only library reproduces creators, roles and credits")     func creatorsImportWhole() async throws {         let fixture = try await M5Fixture()-        let plan = BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload())+        let plan = BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload())          try await fixture.repository.confirmImport(plan: plan)          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        let mori = try #require(creators[BackupV11Fixtures.moriID])+        let mori = try #require(creators[BackupV12Fixtures.moriID])         #expect(mori.name == "Mori Ayane")         #expect(mori.notes == "Also draws.")         #expect(mori.state == .active)         // The archive's own field timestamps, so the next sync fold and a         // repeated import see exactly what the exporting device saw (Q50).-        #expect(mori.nameModifiedAt == BackupV11Fixtures.created)-        #expect(mori.notesModifiedAt == BackupV11Fixtures.created)-        let alias = try #require(creators[BackupV11Fixtures.aliasID])+        #expect(mori.nameModifiedAt == BackupV12Fixtures.created)+        #expect(mori.notesModifiedAt == BackupV12Fixtures.created)+        let alias = try #require(creators[BackupV12Fixtures.aliasID])         #expect(alias.state == .merged)-        #expect(alias.canonicalID == BackupV11Fixtures.moriID)+        #expect(alias.canonicalID == BackupV12Fixtures.moriID)         // A credit naming the alias reads as its survivor on arrival.-        #expect(creators.canonicalID(of: BackupV11Fixtures.aliasID) == BackupV11Fixtures.moriID)+        #expect(creators.canonicalID(of: BackupV12Fixtures.aliasID) == BackupV12Fixtures.moriID)          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())         #expect(roles.identities.count == 5)         #expect(roles.options.map(\.name) == ["author", "artist", "translator", "letterer"])-        #expect(roles[BackupV11Fixtures.lettererRoleID]?.position == 3)-        #expect(roles[BackupV11Fixtures.editorRoleID]?.state == .removed)+        #expect(roles[BackupV12Fixtures.lettererRoleID]?.position == 3)+        #expect(roles[BackupV12Fixtures.editorRoleID]?.state == .removed)         // The three defaults are still the seeded identities the library minted         // at open: the archive's records of them matched by identifier.-        #expect(roles[BackupV11Fixtures.authorRoleID]?.isPristine == true)+        #expect(roles[BackupV12Fixtures.authorRoleID]?.isPristine == true)          let credits = try await fixture.repository.creditRows()         #expect(credits.count == 3)-        let credit = try #require(credits.first { $0.id == BackupV11Fixtures.moriCreditID })+        let credit = try #require(credits.first { $0.id == BackupV12Fixtures.moriCreditID })         #expect(             credit.roleIDs                 == [-                    BackupV11Fixtures.authorRoleID.uuidString,-                    BackupV11Fixtures.editorRoleID.uuidString,+                    BackupV12Fixtures.authorRoleID.uuidString,+                    BackupV12Fixtures.editorRoleID.uuidString,                 ].sorted())         // Req 9.5's tolerance at the store: the credit whose work the archive         // never carried is here, unresolved and nobody's to prune.-        #expect(credits.contains { $0.id == BackupV11Fixtures.orphanCreditID })+        #expect(credits.contains { $0.id == BackupV12Fixtures.orphanCreditID })          let creatorsAfterFirst = try await fixture.repository.creatorRowValues()         let rolesAfterFirst = try await fixture.repository.creatorRoleRowValues()@@ -2012,22 +2520,22 @@ struct BackupV11ImportTests {     @Test("An identifier match takes each field only when the archive's stamp beats the local")     func identifierMatchAppliesPerField() async throws {         let fixture = try await M5Fixture()-        let later = BackupV11Fixtures.created.addingTimeInterval(1_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(1_000)         try await fixture.repository.seedCreators([             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Renamed here",-                nameModifiedAt: later, createdAt: BackupV11Fixtures.created)+                id: BackupV12Fixtures.moriID, name: "Renamed here",+                nameModifiedAt: later, createdAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        let mori = try #require(creators[BackupV11Fixtures.moriID])+        let mori = try #require(creators[BackupV12Fixtures.moriID])         #expect(mori.name == "Renamed here")         #expect(mori.nameModifiedAt == later)         #expect(mori.notes == "Also draws.")-        #expect(mori.notesModifiedAt == BackupV11Fixtures.created)+        #expect(mori.notesModifiedAt == BackupV12Fixtures.created)     }      /// The other half of the rule: an archive field nobody has ever touched@@ -2036,20 +2544,20 @@ struct BackupV11ImportTests {     @Test("A pristine archive field never overrides a reader-touched local one")     func pristineArchiveFieldNeverStands() async throws {         let fixture = try await M5Fixture()-        let touched = BackupV11Fixtures.created.addingTimeInterval(1_000)+        let touched = BackupV12Fixtures.created.addingTimeInterval(1_000)         // A second row of the seeded identity, carrying the reader's rename.         try await fixture.repository.seedCreatorRoles([             SeedCreatorRole(-                id: BackupV11Fixtures.authorRoleID, name: "writer", position: 0,+                id: BackupV12Fixtures.authorRoleID, name: "writer", position: 0,                 nameModifiedAt: touched)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())-        #expect(roles[BackupV11Fixtures.authorRoleID]?.name == "writer")+        #expect(roles[BackupV12Fixtures.authorRoleID]?.name == "writer")     }      /// Req 9.4: `merged` is terminal on the **local** side. An identity that@@ -2065,44 +2573,44 @@ struct BackupV11ImportTests {     func mergedStateIsTerminal() async throws {         let fixture = try await M5Fixture()         let survivor = UUID(uuidString: "c8ea1080-0000-4000-8000-0000000000a1")!-        let later = BackupV11Fixtures.created.addingTimeInterval(3_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(3_000)         try await fixture.repository.seedCreators([             // Locally merged already: the archive's `active` record cannot             // resurrect it, even carrying a later state stamp.-            SeedCreator(id: survivor, name: "Kept", nameModifiedAt: BackupV11Fixtures.created),+            SeedCreator(id: survivor, name: "Kept", nameModifiedAt: BackupV12Fixtures.created),             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Mori Ayane", state: .merged,+                id: BackupV12Fixtures.moriID, name: "Mori Ayane", state: .merged,                 canonicalID: survivor,-                nameModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created),+                nameModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created),             // Locally active, and the archive says it merged into `mori`, which             // resolves here to `survivor`: the archive's later stamp applies.             SeedCreator(-                id: BackupV11Fixtures.aliasID, name: "mori ayane",-                nameModifiedAt: BackupV11Fixtures.created.addingTimeInterval(2_000),-                stateModifiedAt: BackupV11Fixtures.created),+                id: BackupV12Fixtures.aliasID, name: "mori ayane",+                nameModifiedAt: BackupV12Fixtures.created.addingTimeInterval(2_000),+                stateModifiedAt: BackupV12Fixtures.created),         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",                             notes: "Also draws.", stateModifiedAt: later),-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.studioID, name: "Studio Lantern"),-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.aliasID, name: "mori ayane",-                            state: .merged, canonicalID: BackupV11Fixtures.moriID,+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.studioID, name: "Studio Lantern"),+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.aliasID, name: "mori ayane",+                            state: .merged, canonicalID: BackupV12Fixtures.moriID,                             stateModifiedAt: later),                     ])))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        let mori = try #require(creators[BackupV11Fixtures.moriID])+        let mori = try #require(creators[BackupV12Fixtures.moriID])         #expect(mori.state == .merged)         #expect(mori.canonicalID == survivor)-        let alias = try #require(creators[BackupV11Fixtures.aliasID])+        let alias = try #require(creators[BackupV12Fixtures.aliasID])         #expect(alias.state == .merged)         // The chain the archive's pointer made — alias into mori into survivor —         // is collapsed by the same commit's reconcile.@@ -2116,25 +2624,25 @@ struct BackupV11ImportTests {     @Test("An archived merge carrying no survivor leaves a live creator active")     func survivorlessArchivedMergeLeavesTheCreatorActive() async throws {         let fixture = try await M5Fixture()-        let later = BackupV11Fixtures.created.addingTimeInterval(3_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(3_000)         try await fixture.repository.seedCreators([             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                nameModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created)+                id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                nameModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",                             state: .merged, canonicalID: nil, stateModifiedAt: later)                     ])))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        #expect(creators[BackupV11Fixtures.moriID]?.state == .active)+        #expect(creators[BackupV12Fixtures.moriID]?.state == .active)         #expect(creators.options.contains { $0.name == "Mori Ayane" })     } @@ -2148,20 +2656,20 @@ struct BackupV11ImportTests {         let fixture = try await M5Fixture()          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",                             state: .merged, canonicalID: nil)                     ])))          let rows = try await fixture.repository.creatorRowValues()-        let row = try #require(rows.first { $0.id == BackupV11Fixtures.moriID })+        let row = try #require(rows.first { $0.id == BackupV12Fixtures.moriID })         #expect(row.stateRaw == CreatorState.merged.rawValue)         #expect(row.canonicalID == nil, "the survivorless pointer is kept, not invented")         #expect(-            CreatorDirectory(rows: rows)[BackupV11Fixtures.moriID]?.state == .merged,+            CreatorDirectory(rows: rows)[BackupV12Fixtures.moriID]?.state == .merged,             "and the same commit's reconcile leaves it there — there is no chain to collapse")     } @@ -2171,26 +2679,26 @@ struct BackupV11ImportTests {     @Test("An archived merge naming an absent survivor leaves a live creator active")     func archivedMergeNamingAnAbsentSurvivorIsSkipped() async throws {         let fixture = try await M5Fixture()-        let later = BackupV11Fixtures.created.addingTimeInterval(3_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(3_000)         try await fixture.repository.seedCreators([             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                nameModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created)+                id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                nameModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                            state: .merged, canonicalID: BackupV11Fixtures.absentCreatorID,+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                            state: .merged, canonicalID: BackupV12Fixtures.absentCreatorID,                             stateModifiedAt: later)                     ])))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        #expect(creators[BackupV11Fixtures.moriID]?.state == .active)+        #expect(creators[BackupV12Fixtures.moriID]?.state == .active)     }      /// And the positive case: a survivor the archive itself carries, on a@@ -2198,30 +2706,30 @@ struct BackupV11ImportTests {     @Test("An archived merge naming a present survivor and stamped later applies")     func archivedMergeWithAPresentSurvivorApplies() async throws {         let fixture = try await M5Fixture()-        let later = BackupV11Fixtures.created.addingTimeInterval(3_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(3_000)         try await fixture.repository.seedCreators([             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                nameModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created)+                id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                nameModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                            state: .merged, canonicalID: BackupV11Fixtures.studioID,+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                            state: .merged, canonicalID: BackupV12Fixtures.studioID,                             stateModifiedAt: later),-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.studioID, name: "Studio Lantern"),+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.studioID, name: "Studio Lantern"),                     ])))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        let mori = try #require(creators[BackupV11Fixtures.moriID])+        let mori = try #require(creators[BackupV12Fixtures.moriID])         #expect(mori.state == .merged)-        #expect(mori.canonicalID == BackupV11Fixtures.studioID)+        #expect(mori.canonicalID == BackupV12Fixtures.studioID)     }      /// The stamp rule holds for a merge like any other field: an archive older@@ -2230,26 +2738,26 @@ struct BackupV11ImportTests {     @Test("An archived merge older than the local state writes nothing")     func archivedMergeOlderThanTheLocalStateIsIgnored() async throws {         let fixture = try await M5Fixture()-        let touched = BackupV11Fixtures.created.addingTimeInterval(3_000)+        let touched = BackupV12Fixtures.created.addingTimeInterval(3_000)         try await fixture.repository.seedCreators([             SeedCreator(-                id: BackupV11Fixtures.moriID, name: "Mori Ayane",+                id: BackupV12Fixtures.moriID, name: "Mori Ayane",                 nameModifiedAt: touched, stateModifiedAt: touched)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.moriID, name: "Mori Ayane",-                            state: .merged, canonicalID: BackupV11Fixtures.studioID),-                        BackupV11Fixtures.creatorRecord(-                            id: BackupV11Fixtures.studioID, name: "Studio Lantern"),+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.moriID, name: "Mori Ayane",+                            state: .merged, canonicalID: BackupV12Fixtures.studioID),+                        BackupV12Fixtures.creatorRecord(+                            id: BackupV12Fixtures.studioID, name: "Studio Lantern"),                     ])))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())-        #expect(creators[BackupV11Fixtures.moriID]?.state == .active)+        #expect(creators[BackupV12Fixtures.moriID]?.state == .active)     }      /// Q54: an archive record matching a local one **by name only** is inserted@@ -2263,20 +2771,20 @@ struct BackupV11ImportTests {         try await fixture.repository.seedCreators([             SeedCreator(                 id: local, name: "Mori Ayane",-                nameModifiedAt: BackupV11Fixtures.created.addingTimeInterval(-1_000),-                createdAt: BackupV11Fixtures.created.addingTimeInterval(-1_000))+                nameModifiedAt: BackupV12Fixtures.created.addingTimeInterval(-1_000),+                createdAt: BackupV12Fixtures.created.addingTimeInterval(-1_000))         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let creators = CreatorDirectory(rows: try await fixture.repository.creatorRowValues())         // Both records are still there — nothing an import touches is deleted.         #expect(creators[local] != nil)-        #expect(creators[BackupV11Fixtures.moriID] != nil)+        #expect(creators[BackupV12Fixtures.moriID] != nil)         // The earliest-created non-pristine identity survives, and the other         // reads as it (Req 10.3).-        #expect(creators.canonicalID(of: BackupV11Fixtures.moriID) == local)+        #expect(creators.canonicalID(of: BackupV12Fixtures.moriID) == local)         #expect(creators.options.count(where: { $0.name == "Mori Ayane" }) == 1)     } @@ -2292,17 +2800,17 @@ struct BackupV11ImportTests {         try await fixture.repository.seedCreatorRoles([             SeedCreatorRole(                 id: colorist, name: "colorist", position: 3,-                nameModifiedAt: BackupV11Fixtures.created,-                positionModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created)+                nameModifiedAt: BackupV12Fixtures.created,+                positionModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())-        let letterer = try #require(roles[BackupV11Fixtures.lettererRoleID])+        let letterer = try #require(roles[BackupV12Fixtures.lettererRoleID])         #expect(letterer.position == 4, "appended after the local maximum, not at the archive's 3")         #expect(letterer.positionModifiedAt == M5Fixture.epoch)         #expect(roles.options.map(\.name) == ["author", "artist", "translator", "colorist", "letterer"])@@ -2315,41 +2823,41 @@ struct BackupV11ImportTests {     @Test("An archived role state later than the local one is taken, in both directions")     func archivedRoleStateIsTakenByStamp() async throws {         let fixture = try await M5Fixture()-        let later = BackupV11Fixtures.created.addingTimeInterval(1_000)+        let later = BackupV12Fixtures.created.addingTimeInterval(1_000)         try await fixture.repository.seedCreatorRoles([             SeedCreatorRole(-                id: BackupV11Fixtures.lettererRoleID, name: "letterer", position: 3,-                nameModifiedAt: BackupV11Fixtures.created,-                positionModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created),+                id: BackupV12Fixtures.lettererRoleID, name: "letterer", position: 3,+                nameModifiedAt: BackupV12Fixtures.created,+                positionModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created),             SeedCreatorRole(-                id: BackupV11Fixtures.editorRoleID, name: "editor", position: 4,+                id: BackupV12Fixtures.editorRoleID, name: "editor", position: 4,                 state: .removed,-                nameModifiedAt: BackupV11Fixtures.created,-                positionModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created),+                nameModifiedAt: BackupV12Fixtures.created,+                positionModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created),         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(-                    creatorRoles: BackupV11Fixtures.seededRoleRecords + [-                        BackupV11Fixtures.creatorRoleRecord(-                            id: BackupV11Fixtures.lettererRoleID, name: "letterer",+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(+                    creatorRoles: BackupV12Fixtures.seededRoleRecords + [+                        BackupV12Fixtures.creatorRoleRecord(+                            id: BackupV12Fixtures.lettererRoleID, name: "letterer",                             position: 3, state: .removed, stateModifiedAt: later),-                        BackupV11Fixtures.creatorRoleRecord(-                            id: BackupV11Fixtures.editorRoleID, name: "editor",+                        BackupV12Fixtures.creatorRoleRecord(+                            id: BackupV12Fixtures.editorRoleID, name: "editor",                             position: 4, stateModifiedAt: later),                     ])))          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())-        #expect(roles[BackupV11Fixtures.lettererRoleID]?.state == .removed)-        #expect(roles[BackupV11Fixtures.lettererRoleID]?.stateModifiedAt == later)-        #expect(roles[BackupV11Fixtures.editorRoleID]?.state == .active)-        #expect(roles[BackupV11Fixtures.editorRoleID]?.stateModifiedAt == later)+        #expect(roles[BackupV12Fixtures.lettererRoleID]?.state == .removed)+        #expect(roles[BackupV12Fixtures.lettererRoleID]?.stateModifiedAt == later)+        #expect(roles[BackupV12Fixtures.editorRoleID]?.state == .active)+        #expect(roles[BackupV12Fixtures.editorRoleID]?.stateModifiedAt == later)     }      /// The other half: an archived state older than the local one writes@@ -2358,25 +2866,25 @@ struct BackupV11ImportTests {     @Test("An archived role state older than the local one writes nothing")     func archivedRoleStateOlderThanTheLocalOneIsIgnored() async throws {         let fixture = try await M5Fixture()-        let touched = BackupV11Fixtures.created.addingTimeInterval(2_000)+        let touched = BackupV12Fixtures.created.addingTimeInterval(2_000)         try await fixture.repository.seedCreatorRoles([             SeedCreatorRole(-                id: BackupV11Fixtures.lettererRoleID, name: "letterer", position: 3,+                id: BackupV12Fixtures.lettererRoleID, name: "letterer", position: 3,                 state: .removed,-                nameModifiedAt: BackupV11Fixtures.created,-                positionModifiedAt: BackupV11Fixtures.created,+                nameModifiedAt: BackupV12Fixtures.created,+                positionModifiedAt: BackupV12Fixtures.created,                 stateModifiedAt: touched,-                createdAt: BackupV11Fixtures.created)+                createdAt: BackupV12Fixtures.created)         ])          // The archive's `letterer` is active, stamped at `created`.         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())-        #expect(roles[BackupV11Fixtures.lettererRoleID]?.state == .removed)-        #expect(roles[BackupV11Fixtures.lettererRoleID]?.stateModifiedAt == touched)+        #expect(roles[BackupV12Fixtures.lettererRoleID]?.state == .removed)+        #expect(roles[BackupV12Fixtures.lettererRoleID]?.stateModifiedAt == touched)     }      /// Req 9.4's append, over **every** archive-only role rather than the@@ -2390,23 +2898,23 @@ struct BackupV11ImportTests {         try await fixture.repository.seedCreatorRoles([             SeedCreatorRole(                 id: colorist, name: "colorist", position: 3, state: .removed,-                nameModifiedAt: BackupV11Fixtures.created,-                positionModifiedAt: BackupV11Fixtures.created,-                stateModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created)+                nameModifiedAt: BackupV12Fixtures.created,+                positionModifiedAt: BackupV12Fixtures.created,+                stateModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created)         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let roles = CreatorRoleDirectory(             rows: try await fixture.repository.creatorRoleRowValues())-        let letterer = try #require(roles[BackupV11Fixtures.lettererRoleID])+        let letterer = try #require(roles[BackupV12Fixtures.lettererRoleID])         #expect(letterer.position == 4, "past the removed role's 3, not onto it")         #expect(letterer.positionModifiedAt == M5Fixture.epoch)         // The removed one appends too, at the reader's end of the list rather         // than at the place the archive recorded.-        let editor = try #require(roles[BackupV11Fixtures.editorRoleID])+        let editor = try #require(roles[BackupV12Fixtures.editorRoleID])         #expect(editor.state == .removed)         #expect(editor.position == 5)         #expect(editor.positionModifiedAt == M5Fixture.epoch)@@ -2422,54 +2930,54 @@ struct BackupV11ImportTests {     func creditGuardHoldsBothWays() async throws {         let fixture = try await M5Fixture()         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))         let stored = [-            BackupV11Fixtures.authorRoleID.uuidString,-            BackupV11Fixtures.editorRoleID.uuidString,+            BackupV12Fixtures.authorRoleID.uuidString,+            BackupV12Fixtures.editorRoleID.uuidString,         ].sorted()          func reimport(roleIDs: [UUID], modifiedAt: Date) async throws {             try await fixture.repository.confirmImport(-                plan: BackupV11Fixtures.plan(-                    BackupV11Fixtures.creditsPayload(+                plan: BackupV12Fixtures.plan(+                    BackupV12Fixtures.creditsPayload(                         credits: [-                            BackupV11Fixtures.creditRecord(-                                id: BackupV11Fixtures.moriCreditID,-                                workID: BackupV11Fixtures.composedWorkID,-                                creatorID: BackupV11Fixtures.moriID,+                            BackupV12Fixtures.creditRecord(+                                id: BackupV12Fixtures.moriCreditID,+                                workID: BackupV12Fixtures.composedWorkID,+                                creatorID: BackupV12Fixtures.moriID,                                 roleIDs: roleIDs, modifiedAt: modifiedAt)                         ])))         }          func roleIDs() async throws -> [String] {             try await fixture.repository.creditRows()-                .first { $0.id == BackupV11Fixtures.moriCreditID }?.roleIDs ?? []+                .first { $0.id == BackupV12Fixtures.moriCreditID }?.roleIDs ?? []         }          // Older: nothing moves.         try await reimport(-            roleIDs: [BackupV11Fixtures.artistRoleID],-            modifiedAt: BackupV11Fixtures.created.addingTimeInterval(-1_000))+            roleIDs: [BackupV12Fixtures.artistRoleID],+            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(-1_000))         #expect(try await roleIDs() == stored)          // Equal, and a strict subset: the union the row holds stands (Q67).         try await reimport(-            roleIDs: [BackupV11Fixtures.authorRoleID], modifiedAt: BackupV11Fixtures.created)+            roleIDs: [BackupV12Fixtures.authorRoleID], modifiedAt: BackupV12Fixtures.created)         #expect(try await roleIDs() == stored)          // Equal, and a superset: the archive widens it.         try await reimport(             roleIDs: [-                BackupV11Fixtures.authorRoleID, BackupV11Fixtures.editorRoleID,-                BackupV11Fixtures.translatorRoleID,-            ], modifiedAt: BackupV11Fixtures.created)-        #expect(try await roleIDs().contains(BackupV11Fixtures.translatorRoleID.uuidString))+                BackupV12Fixtures.authorRoleID, BackupV12Fixtures.editorRoleID,+                BackupV12Fixtures.translatorRoleID,+            ], modifiedAt: BackupV12Fixtures.created)+        #expect(try await roleIDs().contains(BackupV12Fixtures.translatorRoleID.uuidString))          // Strictly later: the archive wins outright, narrowing included.         try await reimport(-            roleIDs: [BackupV11Fixtures.artistRoleID],-            modifiedAt: BackupV11Fixtures.created.addingTimeInterval(1_000))-        #expect(try await roleIDs() == [BackupV11Fixtures.artistRoleID.uuidString])+            roleIDs: [BackupV12Fixtures.artistRoleID],+            modifiedAt: BackupV12Fixtures.created.addingTimeInterval(1_000))+        #expect(try await roleIDs() == [BackupV12Fixtures.artistRoleID.uuidString])     }      /// Req 9.4's last clause: a pair the import leaves held twice converges per@@ -2480,29 +2988,29 @@ struct BackupV11ImportTests {     func duplicatePairConvergesInTheImport() async throws {         let fixture = try await M5Fixture()         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))         // A second row over the same pair, reached through the alias — created         // after the archive's, so the archive's row is the head.         let through = UUID(uuidString: "c8ed1700-0000-4000-8000-0000000000a1")!         try await fixture.repository.seedCredits([             SeedCredit(-                id: through, workID: BackupV11Fixtures.composedWorkID,-                creatorID: BackupV11Fixtures.aliasID,-                roleIDs: [BackupV11Fixtures.translatorRoleID.uuidString])+                id: through, workID: BackupV12Fixtures.composedWorkID,+                creatorID: BackupV12Fixtures.aliasID,+                roleIDs: [BackupV12Fixtures.translatorRoleID.uuidString])         ])          try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.creditsPayload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.creditsPayload()))          let rows = try await fixture.repository.creditRows()-            .filter { $0.workID == BackupV11Fixtures.composedWorkID }-        #expect(rows.map(\.id) == [BackupV11Fixtures.moriCreditID])+            .filter { $0.workID == BackupV12Fixtures.composedWorkID }+        #expect(rows.map(\.id) == [BackupV12Fixtures.moriCreditID])         #expect(             rows.first?.roleIDs                 == [-                    BackupV11Fixtures.authorRoleID.uuidString,-                    BackupV11Fixtures.editorRoleID.uuidString,-                    BackupV11Fixtures.translatorRoleID.uuidString,+                    BackupV12Fixtures.authorRoleID.uuidString,+                    BackupV12Fixtures.editorRoleID.uuidString,+                    BackupV12Fixtures.translatorRoleID.uuidString,                 ].sorted())     } @@ -2521,37 +3029,37 @@ struct BackupV11ImportTests {         try await fixture.repository.seedCreators([             SeedCreator(                 id: kept, name: "Mori Ayane",-                nameModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created),+                nameModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created),             SeedCreator(                 id: renamed, name: "Ayane Mori",-                nameModifiedAt: BackupV11Fixtures.created,-                createdAt: BackupV11Fixtures.created.addingTimeInterval(1_000)),+                nameModifiedAt: BackupV12Fixtures.created,+                createdAt: BackupV12Fixtures.created.addingTimeInterval(1_000)),         ])         try await fixture.repository.seedCredits([             SeedCredit(-                id: keptCredit, workID: BackupV11Fixtures.composedWorkID, creatorID: kept,-                roleIDs: [BackupV11Fixtures.authorRoleID.uuidString],-                createdAt: BackupV11Fixtures.created,-                modifiedAt: BackupV11Fixtures.created),+                id: keptCredit, workID: BackupV12Fixtures.composedWorkID, creatorID: kept,+                roleIDs: [BackupV12Fixtures.authorRoleID.uuidString],+                createdAt: BackupV12Fixtures.created,+                modifiedAt: BackupV12Fixtures.created),             SeedCredit(-                id: otherCredit, workID: BackupV11Fixtures.composedWorkID,+                id: otherCredit, workID: BackupV12Fixtures.composedWorkID,                 creatorID: renamed,-                roleIDs: [BackupV11Fixtures.artistRoleID.uuidString],-                createdAt: BackupV11Fixtures.created.addingTimeInterval(1_000),-                modifiedAt: BackupV11Fixtures.created.addingTimeInterval(1_000)),+                roleIDs: [BackupV12Fixtures.artistRoleID.uuidString],+                createdAt: BackupV12Fixtures.created.addingTimeInterval(1_000),+                modifiedAt: BackupV12Fixtures.created.addingTimeInterval(1_000)),         ])          // Creators only: no roles, and no credits at all.         try await fixture.repository.confirmImport(-            plan: BackupV11Fixtures.plan(-                BackupV11Fixtures.creditsPayload(+            plan: BackupV12Fixtures.plan(+                BackupV12Fixtures.creditsPayload(                     creators: [-                        BackupV11Fixtures.creatorRecord(+                        BackupV12Fixtures.creatorRecord(                             id: renamed, name: "Mori Ayane",-                            nameModifiedAt: BackupV11Fixtures.created+                            nameModifiedAt: BackupV12Fixtures.created                                 .addingTimeInterval(2_000),-                            createdAt: BackupV11Fixtures.created.addingTimeInterval(1_000))+                            createdAt: BackupV12Fixtures.created.addingTimeInterval(1_000))                     ],                     creatorRoles: [], credits: []))) @@ -2559,53 +3067,65 @@ struct BackupV11ImportTests {         #expect(creators.canonicalID(of: renamed) == kept)          let rows = try await fixture.repository.creditRows()-            .filter { $0.workID == BackupV11Fixtures.composedWorkID }+            .filter { $0.workID == BackupV12Fixtures.composedWorkID }         #expect(rows.map(\.id) == [keptCredit])         #expect(             rows.first?.roleIDs                 == [-                    BackupV11Fixtures.authorRoleID.uuidString,-                    BackupV11Fixtures.artistRoleID.uuidString,+                    BackupV12Fixtures.authorRoleID.uuidString,+                    BackupV12Fixtures.artistRoleID.uuidString,                 ].sorted())     }      // 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 11/12 archive exported from one library imports whole into another")+    /// real gate: a library holding characters, places, both suppression tables+    /// and coverage, exported and restored into a different one.+    @Test("A 12/13 archive exported from one library imports whole into another")     func exportedArchivesRoundTrip() async throws {         let source = try await M5Fixture()         try await source.repository.confirmImport(-            plan: BackupV11Fixtures.plan(BackupV11Fixtures.payload()))+            plan: BackupV12Fixtures.plan(BackupV12Fixtures.payload())) -        let payload = try await source.repository.backupV11Snapshot()+        let payload = try await source.repository.backupV12Snapshot()         let plan = try BackupImporter.plan(-            from: try BackupV11Codec.encode(-                payload: payload, metadata: BackupV11Fixtures.metadata()))+            from: try BackupV12Codec.encode(+                payload: payload, metadata: BackupV12Fixtures.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 == BackupV11Fixtures.groverID })+        let grover = try #require(characters.first { $0.id == BackupV12Fixtures.groverID })         #expect(grover.name == "Grover")         #expect(grover.facts.map(\.quote) == ["promised to guide them home"])-        #expect(grover.workID == BackupV11Fixtures.workID)+        #expect(grover.workID == BackupV12Fixtures.workID)         #expect(             try await target.repository.m5SuppressionRows()-                .contains { $0.id == BackupV11Fixtures.suppressionID })+                .contains { $0.id == BackupV12Fixtures.suppressionID })+        #expect(+            try await target.repository.m5EntryCoverage(BackupV12Fixtures.entryID)+                == BackupV12Fixtures.noteFingerprint)++        // Req 5.1: the second kind makes the same trip, through the same+        // exporter, codec and gate.+        let keep = try #require(+            try await target.repository.m5AllPlaces()+                .first { $0.id == BackupV12Fixtures.keepID })+        #expect(keep.name == "The High Keep")+        #expect(keep.facts.map(\.quote) == ["above the pass"])+        #expect(keep.workID == BackupV12Fixtures.workID)         #expect(-            try await target.repository.m5EntryCoverage(BackupV11Fixtures.entryID)-                == BackupV11Fixtures.noteFingerprint)+            try await target.repository.m5PlaceSuppressionRows()+                .contains { $0.id == BackupV12Fixtures.placeSuppressionID })     } }  // MARK: - Test Doubles -private final class MockV11SnapshotProvider: BackupV11SnapshotProviding, @unchecked Sendable {-    let payload: BackupV11Payload-    init(payload: BackupV11Payload) { self.payload = payload }-    func backupV11Snapshot() async throws -> BackupV11Payload { payload }+private final class MockV12SnapshotProvider: BackupV12SnapshotProviding, @unchecked Sendable {+    let payload: BackupV12Payload+    init(payload: BackupV12Payload) { self.payload = payload }+    func backupV12Snapshot() async throws -> BackupV12Payload { payload } }
Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swift Renamed +205 / -130
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11Fixtures.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swiftsimilarity index 80%rename from Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11Fixtures.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swiftindex 8f5cbe9..4bfe742 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11Fixtures.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BackupV12Fixtures.swift@@ -3,20 +3,21 @@ import Foundation  @testable import AsterismCore -/// Shared builders for 11/12 payloads — the only archive shape the app reads or-/// writes. 11/12 adds a creator table, a role list and a credit table (T-2316),-/// over a V12 store.+/// Shared builders for 12/13 payloads — the only archive shape the app reads or+/// writes. 12/13 adds a place table and a place-suppression table (T-2276), over+/// a V13 store. ///-/// It absorbed the 4/4, 5/6, 6/7, 7/8, 8/9 and 9/10 fixture enums as each-/// generation's read and write paths were deleted. 7/8 changed the records-/// themselves: a Work names no site, a membership record names the Work, the-/// citations travel as one blob, and the coverage table is gone — so a payload-/// here is built site-first, membership-second, and every Entry's Work holds a-/// membership on that Entry's hostname. 8/9 changed what a citation *is*+/// It absorbed the 4/4, 5/6, 6/7, 7/8, 8/9, 9/10, 10/11 and 11/12 fixture enums+/// as each generation's read and write paths were deleted. 7/8 changed the+/// records themselves: a Work names no site, a membership record names the Work,+/// the citations travel as one blob, and the coverage table is gone — so a+/// payload here is built site-first, membership-second, and every Entry's Work+/// holds a membership on that Entry's hostname. 8/9 changed what a citation *is* /// (T-2281): the rule's UUID and nothing else. 9/10 added a Work's work status,-/// reading status and verdict (T-2306). 11/12 adds a series table, a link table-/// and a Work's series membership (T-2308), over a V11 store.-enum BackupV11Fixtures {+/// reading status and verdict (T-2306). 10/11 added a series table, a link table+/// and a Work's series membership (T-2308). 11/12 added the creator table, the+/// role list and the credits (T-2316).+enum BackupV12Fixtures {     static let created = Date(timeIntervalSince1970: 1_000_000)      static let novelTypeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000a1")!@@ -42,6 +43,14 @@ enum BackupV11Fixtures {     static let suppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000001")!     static let factSuppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000002")! +    /// The place the 12/13 fixtures carry, and the one whose `workID` resolves to+    /// nothing — the tolerated orphan of Req 5.5, which a place expresses with a+    /// dangling id rather than with `nil` (Q60, Q67).+    static let keepID = UUID(uuidString: "91ACE000-0000-4000-8000-000000000001")!+    static let orphanPlaceID = UUID(uuidString: "91ACE000-0000-4000-8000-000000000002")!+    static let placeSuppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000011")!+    static let placeFactSuppressionID = UUID(uuidString: "5099E5ED-0000-4000-8000-000000000012")!+     // MARK: - Work types      static func workTypeRecord(@@ -51,8 +60,8 @@ enum BackupV11Fixtures {         canonicalID: UUID? = nil,         createdAt: Date = created,         modifiedAt: Date = created-    ) -> BackupV11WorkType {-        BackupV11WorkType(+    ) -> BackupV12WorkType {+        BackupV12WorkType(             id: id, name: name, stateRaw: state.rawValue, canonicalID: canonicalID,             createdAt: createdAt, modifiedAt: modifiedAt)     }@@ -70,8 +79,8 @@ enum BackupV11Fixtures {         urlIdentityState: WorkURLIdentityState = .none,         urlIdentityRuleID: UUID? = nil,         workURLString: String? = nil-    ) -> BackupV11Membership {-        BackupV11Membership(+    ) -> BackupV12Membership {+        BackupV12Membership(             id: id, workID: workID, hostname: hostname, createdAt: createdAt,             urlIdentity: urlIdentity, urlIdentityState: urlIdentityState,             urlIdentityRuleID: urlIdentityRuleID, workURLString: workURLString)@@ -92,13 +101,13 @@ enum BackupV11Fixtures {         brokenAlias: Bool = false,         workTypeID: UUID? = novelTypeID,         typeName: String? = "novel",-        workTypes: [BackupV11WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]-    ) -> BackupV11Payload {+        workTypes: [BackupV12WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]+    ) -> BackupV12Payload {         let host = minimalHost         let patternID = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb")!         let rawURL = "https://example.com/read/7" -        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: host, version: 1, isActive: activePattern,             createdAt: created,             definition: StoredPatternDefinition(@@ -106,17 +115,17 @@ enum BackupV11Fixtures {                     work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),                     ignored: []))) -        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: host, displayName: "Example", mode: .taught, junkSuffixRule: nil) -        let work = BackupV11Work(+        let work = BackupV12Work(             id: minimalWorkID, displayTitle: "Constellation", lastParsedTitle: "Constellation",             genericNotes: "", genreTags: [], titleProvenance: .parsed,             workStatus: .ongoing, readingStatus: .reading, verdict: "",             workTypeID: workTypeID, typeName: typeName,             createdAt: created, modifiedAt: created) -        let entry = BackupV11Entry(+        let entry = BackupV12Entry(             id: minimalEntryID, captureTitle: "Chapter 7", captureTitleSource: .host,             rawURL: rawURL, canonicalURL: nil, hostname: host,             entryIdentityKey: rawURL,@@ -127,7 +136,7 @@ enum BackupV11Fixtures {             modifiedAt: created, workID: minimalWorkID, intentionallyUnattached: false,             citations: EntryCitations(workAssignment: .manual)) -        return BackupV11Payload(+        return BackupV12Payload(             entries: [entry], works: [work], sites: [site],             titlePatterns: [pattern], urlRules: [], workTypes: workTypes,             memberships: [@@ -147,8 +156,8 @@ enum BackupV11Fixtures {         dropNameContributor: Bool = false,         workTypeID: UUID? = novelTypeID,         typeName: String? = "novel",-        workTypes: [BackupV11WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]-    ) -> BackupV11Payload {+        workTypes: [BackupV12WorkType] = [workTypeRecord(id: novelTypeID, name: "novel")]+    ) -> BackupV12Payload {         let host = "example.com"         let patternID = UUID(uuidString: "cccccccc-cccc-cccc-cccc-cccccccccccc")!         let ruleID = UUID(uuidString: "dddddddd-dddd-dddd-dddd-dddddddddddd")!@@ -156,25 +165,25 @@ enum BackupV11Fixtures {         let workName = "Actual Title"          // The whole-title rule names the Work by trimming the boilerplate prefix.-        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,             definition: StoredPatternDefinition(                 definition: .wholeTitle, trimPrefix: "TtH • Story • "))          // A sequence-only query rule extracts "94" from the raw URL.-        let rule = BackupV11URLRule(+        let rule = BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: created,             origin: .readerTaught,             definition: .sequence(locator: .query(name: ExactScalarString("chapter"))),             siteHostname: host) -        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: host, displayName: "Example", mode: .taught, junkSuffixRule: nil)          // Req 8.1: the composed fixture's Work carries all three off their         // defaults, so a round trip that dropped one would show as a difference         // rather than as a default that happened to match.-        let work = BackupV11Work(+        let work = BackupV12Work(             id: composedWorkID, displayTitle: workName, lastParsedTitle: workName,             genericNotes: "", genreTags: [], titleProvenance: .parsed,             workStatus: .finished, readingStatus: .abandoned,@@ -188,7 +197,7 @@ enum BackupV11Fixtures {                 hostname: ExactScalarString(host), workName: ExactScalarString(workName),                 chapterSequence: ExactScalarString("94"))) -        let entry = BackupV11Entry(+        let entry = BackupV12Entry(             id: entryID, captureTitle: "TtH • Story • Actual Title", captureTitleSource: .host,             rawURL: rawURL, canonicalURL: nil, hostname: host,             entryIdentityKey: v3Key, conservativeIdentityKey: rawURL,@@ -203,7 +212,7 @@ enum BackupV11Fixtures {                 chapterSequence: CitedRule(id: ruleID),                 workAssignment: .pattern(CitedRule(id: patternID)))) -        return BackupV11Payload(+        return BackupV12Payload(             entries: [entry], works: [work], sites: [site],             titlePatterns: [pattern], urlRules: [rule], workTypes: workTypes,             memberships: [@@ -224,8 +233,8 @@ enum BackupV11Fixtures {         notes: String = "Read 2.5 after 2.",         createdAt: Date = created,         modifiedAt: Date = created-    ) -> BackupV11Series {-        BackupV11Series(+    ) -> BackupV12Series {+        BackupV12Series(             id: id, name: name, notes: notes, createdAt: createdAt, modifiedAt: modifiedAt)     } @@ -238,30 +247,30 @@ enum BackupV11Fixtures {         type: String = "adaptation",         createdAt: Date = created,         modifiedAt: Date = created-    ) -> BackupV11Link {-        BackupV11Link(+    ) -> BackupV12Link {+        BackupV12Link(             id: id, lowerWorkID: a, higherWorkID: b, linkType: type,             createdAt: createdAt, modifiedAt: modifiedAt)     }      /// `composedPayload` plus a second Work on the same site, a series holding     /// both, and a link between them: the smallest payload that exercises every-    /// V11 shape at once.+    /// V12 shape at once.     static func seriesPayload(-        series: [BackupV11Series] = [seriesRecord()],-        links: [BackupV11Link] = [linkRecord()],+        series: [BackupV12Series] = [seriesRecord()],+        links: [BackupV12Link] = [linkRecord()],         firstMembership: (series: UUID, position: Double)? = (seriesID, 1),         secondMembership: (series: UUID, position: Double)? = (seriesID, 2.5)-    ) -> BackupV11Payload {+    ) -> BackupV12Payload {         let base = composedPayload()         let host = "example.com"-        let second = BackupV11Work(+        let second = BackupV12Work(             id: secondWorkID, displayTitle: "The Side Story", lastParsedTitle: nil,             genericNotes: "", genreTags: [], titleProvenance: .manual,             workStatus: .ongoing, readingStatus: .reading, verdict: "",             workTypeID: nil, typeName: nil, createdAt: created, modifiedAt: created,             seriesID: secondMembership?.series, seriesPosition: secondMembership?.position)-        return BackupV11Payload(+        return BackupV12Payload(             entries: base.entries,             works: base.works.map { placed($0, membership: firstMembership) } + [second],             sites: base.sites,@@ -277,12 +286,12 @@ enum BackupV11Fixtures {             links: links)     } -    /// The composed Work with a membership pair. `BackupV11Work`'s fields are+    /// The composed Work with a membership pair. `BackupV12Work`'s fields are     /// `let`, so a copy is a full restatement.     static func placed(-        _ record: BackupV11Work, membership: (series: UUID, position: Double)?-    ) -> BackupV11Work {-        BackupV11Work(+        _ record: BackupV12Work, membership: (series: UUID, position: Double)?+    ) -> BackupV12Work {+        BackupV12Work(             id: record.id, displayTitle: record.displayTitle,             lastParsedTitle: record.lastParsedTitle, genericNotes: record.genericNotes,             genreTags: record.genreTags, titleProvenance: record.titleProvenance,@@ -297,13 +306,13 @@ enum BackupV11Fixtures {     /// half-set and out-of-range shapes no writer produces and both archive     /// doors refuse (Req 13.5).     static func payloadWithMembership(-        seriesID: UUID?, position: Double?, series: [BackupV11Series] = [seriesRecord()]-    ) -> BackupV11Payload {+        seriesID: UUID?, position: Double?, series: [BackupV12Series] = [seriesRecord()]+    ) -> BackupV12Payload {         let base = composedPayload()-        return BackupV11Payload(+        return BackupV12Payload(             entries: base.entries,             works: base.works.map {-                BackupV11Work(+                BackupV12Work(                     id: $0.id, displayTitle: $0.displayTitle,                     lastParsedTitle: $0.lastParsedTitle, genericNotes: $0.genericNotes,                     genreTags: $0.genreTags, titleProvenance: $0.titleProvenance,@@ -355,8 +364,8 @@ enum BackupV11Fixtures {         notesModifiedAt: Date = created,         stateModifiedAt: Date = created,         createdAt: Date = created-    ) -> BackupV11Creator {-        BackupV11Creator(+    ) -> BackupV12Creator {+        BackupV12Creator(             id: id, name: name, nameModifiedAt: nameModifiedAt, notes: notes,             notesModifiedAt: notesModifiedAt, stateRaw: state.rawValue,             stateModifiedAt: stateModifiedAt, canonicalID: canonicalID,@@ -374,8 +383,8 @@ enum BackupV11Fixtures {         positionModifiedAt: Date = created,         stateModifiedAt: Date = created,         createdAt: Date = created-    ) -> BackupV11CreatorRole {-        BackupV11CreatorRole(+    ) -> BackupV12CreatorRole {+        BackupV12CreatorRole(             id: id, name: name, nameModifiedAt: nameModifiedAt, position: position,             positionModifiedAt: positionModifiedAt, stateRaw: state.rawValue,             stateModifiedAt: stateModifiedAt, canonicalID: canonicalID,@@ -386,7 +395,7 @@ enum BackupV11Fixtures {     /// One of the three defaults exactly as a library that has only ever seeded     /// holds it: the frozen identity, the shipped spelling and place, and the     /// pristine sentinel on every timestamp.-    static func seededRoleRecord(_ seed: CreatorRoleSeeding.Seed) -> BackupV11CreatorRole {+    static func seededRoleRecord(_ seed: CreatorRoleSeeding.Seed) -> BackupV12CreatorRole {         creatorRoleRecord(             id: seed.id, name: seed.name, position: seed.position,             nameModifiedAt: pristine, positionModifiedAt: pristine,@@ -394,7 +403,7 @@ enum BackupV11Fixtures {     }      /// The three seeds, in list order.-    static var seededRoleRecords: [BackupV11CreatorRole] {+    static var seededRoleRecords: [BackupV12CreatorRole] {         CreatorRoleSeeding.seeds.map(seededRoleRecord)     } @@ -405,8 +414,8 @@ enum BackupV11Fixtures {         roleIDs: [UUID] = [],         createdAt: Date = created,         modifiedAt: Date = created-    ) -> BackupV11Credit {-        BackupV11Credit(+    ) -> BackupV12Credit {+        BackupV12Credit(             id: id, workID: workID, creatorID: creatorID,             roleIDs: roleIDs.map(\.uuidString).sorted(),             createdAt: createdAt, modifiedAt: modifiedAt)@@ -414,7 +423,7 @@ enum BackupV11Fixtures {      /// The two creators, the alias between them, and the archive's role list:     /// the three seeds plus a reader-added `letterer` and a removed `editor`.-    static var creatorRecords: [BackupV11Creator] {+    static var creatorRecords: [BackupV12Creator] {         [             creatorRecord(id: moriID, name: "Mori Ayane", notes: "Also draws."),             creatorRecord(id: studioID, name: "Studio Lantern"),@@ -423,7 +432,7 @@ enum BackupV11Fixtures {         ]     } -    static var creatorRoleRecords: [BackupV11CreatorRole] {+    static var creatorRoleRecords: [BackupV12CreatorRole] {         seededRoleRecords + [             creatorRoleRecord(id: lettererRoleID, name: "letterer", position: 3),             creatorRoleRecord(@@ -435,7 +444,7 @@ enum BackupV11Fixtures {     /// including the removed one, one naming the **alias** creator, and one whose     /// work the archive does not carry — the tolerated unresolved shape of     /// Req 9.5.-    static var creditRecords: [BackupV11Credit] {+    static var creditRecords: [BackupV12Credit] {         [             creditRecord(                 id: moriCreditID, workID: composedWorkID, creatorID: moriID,@@ -453,17 +462,19 @@ enum BackupV11Fixtures {     /// `seriesPayload` plus the whole V12 surface: the creator table, the role     /// list and the credits over both works.     static func creditsPayload(-        creators: [BackupV11Creator]? = nil,-        creatorRoles: [BackupV11CreatorRole]? = nil,-        credits: [BackupV11Credit]? = nil-    ) -> BackupV11Payload {+        creators: [BackupV12Creator]? = nil,+        creatorRoles: [BackupV12CreatorRole]? = nil,+        credits: [BackupV12Credit]? = nil+    ) -> BackupV12Payload {         let base = seriesPayload()-        return BackupV11Payload(+        return BackupV12Payload(             entries: base.entries, works: base.works, sites: base.sites,             titlePatterns: base.titlePatterns, urlRules: base.urlRules,             workTypes: base.workTypes, memberships: base.memberships,             distinctPairs: base.distinctPairs, characters: base.characters,-            suppressions: base.suppressions, series: base.series, links: base.links,+            suppressions: base.suppressions, places: base.places,+            placeSuppressions: base.placeSuppressions,+            series: base.series, links: base.links,             creators: creators ?? creatorRecords,             creatorRoles: creatorRoles ?? creatorRoleRecords,             credits: credits ?? creditRecords)@@ -476,12 +487,12 @@ enum BackupV11Fixtures {     /// unanchored — which selection would happily resolve on a single-component     /// path, so the import gate's `validate` call is the only thing standing     /// between such an archive and the store (Req 1.3, Q5/Q7).-    static func unanchoredRulePayload(leftAnchored: Bool) -> BackupV11Payload {+    static func unanchoredRulePayload(leftAnchored: Bool) -> BackupV12Payload {         let host = "unanchored.example"         let patternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff1")!         let ruleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff2")! -        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,             definition: StoredPatternDefinition(                 definition: .segment(@@ -489,16 +500,16 @@ enum BackupV11Fixtures {                     ignored: [])))          let left: PathAnchor = leftAnchored ? .literal(ExactScalarString("series")) : .unanchored-        let rule = BackupV11URLRule(+        let rule = BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: created,             origin: .readerTaught,             definition: .work(locator: .pathBracketed(left: left, right: .unanchored)),             siteHostname: host) -        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: host, displayName: "Unanchored", mode: .taught, junkSuffixRule: nil) -        return BackupV11Payload(+        return BackupV12Payload(             entries: [], works: [], sites: [site],             titlePatterns: [pattern], urlRules: [rule], workTypes: [])     }@@ -513,16 +524,16 @@ enum BackupV11Fixtures {     /// Fixed UUIDs and a fixed date, so the encoded bytes are stable and can be     /// asserted on directly — which a fixture generated by a teaching commit     /// cannot be (it mints random UUIDs and wall-clock timestamps).-    static func combinedRulePayload(presence: URLSequencePresence) -> BackupV11Payload {+    static func combinedRulePayload(presence: URLSequencePresence) -> BackupV12Payload {         let patternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff3")!         let ruleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff4")! -        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: combinedRuleHost, version: 1, isActive: true,             createdAt: created,             definition: StoredPatternDefinition(definition: .wholeTitle)) -        let rule = BackupV11URLRule(+        let rule = BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: created,             origin: .readerTaught,             definition: .combined(@@ -535,11 +546,11 @@ enum BackupV11Fixtures {                     sequencePresence: presence)),             siteHostname: combinedRuleHost) -        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: combinedRuleHost, displayName: "Combined", mode: .taught,             junkSuffixRule: nil) -        return BackupV11Payload(+        return BackupV12Payload(             entries: [], works: [], sites: [site],             titlePatterns: [pattern], urlRules: [rule], workTypes: [])     }@@ -555,6 +566,7 @@ enum BackupV11Fixtures {     static let sequencePresenceOmittedPayloadJSON =         #"{"characters":[],"creatorRoles":[],"creators":[],"credits":[],"distinctPairs":[],"#         + #""entries":[],"links":[],"memberships":[],"#+        + #""placeSuppressions":[],"places":[],"#         + #""series":[],"sites":"#         + #"[{"displayName":"Combined","hostname":"combined.example","mode":"taught"}],"#         + #""suppressions":[],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","#@@ -568,11 +580,11 @@ enum BackupV11Fixtures {         + #""origin":"readerTaught","siteHostname":"combined.example","version":1}],"#         + #""workTypes":[],"works":[]}"# -    /// A payload literal wrapped in the 11/12 envelope, with the checksum taken+    /// A payload literal wrapped in the 12/13 envelope, with the checksum taken     /// over that literal text rather than over a re-encoding of it.     ///     /// The checksum is what makes such a fixture a test rather than a-    /// restatement: `BackupV11Codec.decode` re-encodes the payload it decoded and+    /// restatement: `BackupV12Codec.decode` re-encodes the payload it decoded and     /// compares a SHA-256, so a literal carrying a key the codec does not write     /// back — an omitted spelling the build no longer produces, or a citation     /// `version` it now ignores — fails with `checksumMismatch` (Decision 1).@@ -583,9 +595,9 @@ enum BackupV11Fixtures {         let checksum = SHA256.hash(data: Data(payload.utf8))             .map { String(format: "%02x", $0) }.joined()         return Data(-            (#"{"appBuild":"\#(appBuild)","backupFormatVersion":11,"#+            (#"{"appBuild":"\#(appBuild)","backupFormatVersion":12,"#                 + #""capabilityGate":"multi-site","checksum":"\#(checksum)","#-                + #""databaseSchemaVersion":12,"entryCount":\#(entryCount),"#+                + #""databaseSchemaVersion":13,"entryCount":\#(entryCount),"#                 + #""exportedAt":"1970-01-12T13:46:40.000Z","payload":\#(payload),"#                 + #""workCount":\#(workCount)}"#).utf8)     }@@ -598,7 +610,7 @@ enum BackupV11Fixtures {      /// `composedPayload()` as the current encoder writes it, with `"version":3`     /// hand-added to the Entry's chapter-sequence citation — the shape a build-    /// before T-2281 wrote, and the one a 11/12 archive may not carry.+    /// before T-2281 wrote, and the one a 12/13 archive may not carry.     ///     /// Captured from `BackupCanonicalJSON.encoder()` and pasted, for the reason     /// Q17 gives: an encoder that no longer writes the key cannot produce@@ -623,7 +635,8 @@ enum BackupV11Fixtures {         + #""workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"links":[],"memberships":"#         + #"[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"example.com","#         + #""id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE1","urlIdentityState":"none","#-        + #""workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"series":[],"sites":"#+        + #""workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"#+        + #""placeSuppressions":[],"places":[],"series":[],"sites":"#         + #"[{"displayName":"Example","hostname":"example.com","mode":"taught"}],"#         + #""suppressions":[],"#         + #""titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":"#@@ -656,7 +669,7 @@ enum BackupV11Fixtures {     // MARK: - A Work missing the three status fields (Req 8.1)      /// The bytes this build writes, with `workStatus`, `readingStatus` and-    /// `verdict` struck from the one Work record — the shape a 11/12 file may not+    /// `verdict` struck from the one Work record — the shape a 12/13 file may not     /// carry, because all three are required and there is no default (Q34).     ///     /// It starts from the version-free literal because that is already the@@ -669,6 +682,18 @@ enum BackupV11Fixtures {             .replacingOccurrences(of: #""workStatus":"ongoing","#, with: "")     } +    // MARK: - A payload missing the two place arrays (Req 5.1)++    /// The same literal with the two place arrays struck out — the **shape** a+    /// pre-feature build writes, wearing a 12/13 envelope. Both arrays are+    /// declared without `decodeIfPresent` and without a default (Q51), so this+    /// fails the typed decode rather than restoring a library that asserts the+    /// reader accepted no places.+    static var placeArraysOmittedPayloadJSON: String {+        citationVersionFreePayloadJSON.replacingOccurrences(+            of: #""placeSuppressions":[],"places":[],"#, with: "")+    }+     // MARK: - Duplicate and non-greatest rule versions (Req 3.2)      /// A Site holding two title patterns at version 1 — one retired, one active@@ -677,21 +702,21 @@ enum BackupV11Fixtures {     /// Both shapes were refusals until T-2281 retired the invariant: a Site's     /// rule versions had to be unique and the marked row had to hold the     /// greatest. The column is advisory now, so this archive imports.-    static func duplicateVersionsPayload() -> BackupV11Payload {+    static func duplicateVersionsPayload() -> BackupV12Payload {         let host = "versions.example"         let retiredPatternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff5")!         let activePatternID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff6")!         let retiredRuleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff7")!         let currentRuleID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-fffffffffff8")! -        func pattern(_ id: UUID, active: Bool, createdAt: Date) -> BackupV11TitlePattern {-            BackupV11TitlePattern(+        func pattern(_ id: UUID, active: Bool, createdAt: Date) -> BackupV12TitlePattern {+            BackupV12TitlePattern(                 id: id, siteHostname: host, version: 1, isActive: active, createdAt: createdAt,                 definition: StoredPatternDefinition(definition: .wholeTitle))         } -        func rule(_ id: UUID, version: Int, current: Bool, createdAt: Date) -> BackupV11URLRule {-            BackupV11URLRule(+        func rule(_ id: UUID, version: Int, current: Bool, createdAt: Date) -> BackupV12URLRule {+            BackupV12URLRule(                 id: id, version: version, isCurrent: current, createdAt: createdAt,                 origin: .readerTaught,                 definition: .sequence(@@ -699,10 +724,10 @@ enum BackupV11Fixtures {                 siteHostname: host)         } -        return BackupV11Payload(+        return BackupV12Payload(             entries: [], works: [],             sites: [-                BackupV11Site(+                BackupV12Site(                     hostname: host, displayName: "Versions", mode: .taught, junkSuffixRule: nil)             ],             titlePatterns: [@@ -720,21 +745,21 @@ enum BackupV11Fixtures {      // MARK: - Two current URL rules (illegal) -    static func twoCurrentRulePayload() -> BackupV11Payload {+    static func twoCurrentRulePayload() -> BackupV12Payload {         let host = "dup.example"         let patternID = UUID()         let ruleA = UUID()         let ruleB = UUID() -        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: host, version: 1, isActive: true, createdAt: created,             definition: StoredPatternDefinition(                 definition: .segment(                     work: try! SegmentRangeSpec(origin: .start, offset: 0, length: 1),                     ignored: []))) -        func rule(_ id: UUID, _ version: Int) -> BackupV11URLRule {-            BackupV11URLRule(+        func rule(_ id: UUID, _ version: Int) -> BackupV12URLRule {+            BackupV12URLRule(                 id: id, version: version, isCurrent: true, createdAt: created,                 origin: .readerTaught,                 definition: .sequence(@@ -742,10 +767,10 @@ enum BackupV11Fixtures {                 siteHostname: host)         } -        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: host, displayName: "Dup", mode: .taught, junkSuffixRule: nil) -        return BackupV11Payload(+        return BackupV12Payload(             entries: [], works: [], sites: [site],             titlePatterns: [pattern], urlRules: [rule(ruleA, 1), rule(ruleB, 2)],             workTypes: [])@@ -758,8 +783,8 @@ enum BackupV11Fixtures {         quote: String = "promised to guide them home",         nameKey: String = "grover",         source: SourceRef = .entry(entryID)-    ) -> CharacterFact {-        CharacterFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    ) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: nameKey, source: source)     }      static func character(@@ -769,11 +794,11 @@ enum BackupV11Fixtures {         nameKey: String = "grover",         aliases: [String] = ["Klar"],         note: String = "The guide.",-        facts: [CharacterFact] = [fact()],+        facts: [RecordFact] = [fact()],         createdAt: Date = created,         modifiedAt: Date = created-    ) -> BackupV11Character {-        BackupV11Character(+    ) -> BackupV12Character {+        BackupV12Character(             id: id, workID: workID, name: name, nameKey: nameKey, aliases: aliases,             note: note, facts: facts, createdAt: createdAt, modifiedAt: modifiedAt)     }@@ -787,8 +812,54 @@ enum BackupV11Fixtures {         evidence: String? = nil,         status: CharacterSuppressionStatus = .active,         actionAt: Date = created-    ) -> BackupV11Suppression {-        BackupV11Suppression(+    ) -> BackupV12Suppression {+        BackupV12Suppression(+            id: id, workID: workID, kindRaw: kind.rawValue, nameKey: nameKey,+            sourceKindRaw: source?.kindRaw, sourceEntryID: source?.entryID,+            evidence: evidence, statusRaw: status.rawValue, actionAt: actionAt)+    }++    // MARK: - Place records (`place-extraction` Req 5.1)++    /// The place twin of `character(…)`. `workID` is **not** optional here: the+    /// table names its owner in a column, so the caller passes the id it names+    /// whether or not anything answers for it (Q60).+    static func place(+        id: UUID = keepID,+        workID: UUID = workID,+        name: String = "The High Keep",+        nameKey: String = "high keep",+        aliases: [String] = ["The Keep"],+        note: String = "The fortress above the pass.",+        facts: [RecordFact] = [placeFact()],+        createdAt: Date = created,+        modifiedAt: Date = created+    ) -> BackupV12Place {+        BackupV12Place(+            id: id, workID: workID, name: name, nameKey: nameKey, aliases: aliases,+            note: note, facts: facts, createdAt: createdAt, modifiedAt: modifiedAt)+    }++    static func placeFact(+        statement: String = "Sits above the pass.",+        quote: String = "above the pass",+        nameKey: String = "high keep",+        source: SourceRef = .entry(entryID)+    ) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: nameKey, source: source)+    }++    static func placeSuppression(+        id: UUID = placeSuppressionID,+        workID: UUID = workID,+        kind: CharacterSuppressionKind = .candidate,+        nameKey: String = "low road",+        source: SourceRef? = nil,+        evidence: String? = nil,+        status: CharacterSuppressionStatus = .active,+        actionAt: Date = created+    ) -> BackupV12PlaceSuppression {+        BackupV12PlaceSuppression(             id: id, workID: workID, kindRaw: kind.rawValue, nameKey: nameKey,             sourceKindRaw: source?.kindRaw, sourceEntryID: source?.entryID,             evidence: evidence, statusRaw: status.rawValue, actionAt: actionAt)@@ -805,13 +876,15 @@ enum BackupV11Fixtures {     /// it and the defaults are taken from them — a caller passing something else     /// is describing a stale pair on purpose.     static func payload(-        characters: [BackupV11Character] = [character()],-        suppressions: [BackupV11Suppression] = [suppression()],+        characters: [BackupV12Character] = [character()],+        suppressions: [BackupV12Suppression] = [suppression()],+        places: [BackupV12Place] = [place()],+        placeSuppressions: [BackupV12PlaceSuppression] = [placeSuppression()],         entryFingerprint: String? = noteFingerprint,         workFingerprint: String? = genericNotesFingerprint-    ) -> BackupV11Payload {+    ) -> BackupV12Payload {         let base = composedPayload()-        return BackupV11Payload(+        return BackupV12Payload(             entries: base.entries.map { noted($0, fingerprint: entryFingerprint) },             works: base.works.map { annotated($0, fingerprint: workFingerprint) },             sites: base.sites,@@ -821,21 +894,23 @@ enum BackupV11Fixtures {             memberships: base.memberships,             distinctPairs: base.distinctPairs,             characters: characters,-            suppressions: suppressions)+            suppressions: suppressions,+            places: places,+            placeSuppressions: placeSuppressions)     } -    static func metadata(appBuild: String = "test-11", exportedAt: Date = created)-        -> BackupV11Metadata+    static func metadata(appBuild: String = "test-12", exportedAt: Date = created)+        -> BackupV12Metadata     {-        BackupV11Metadata(appBuild: appBuild, exportedAt: exportedAt)+        BackupV12Metadata(appBuild: appBuild, exportedAt: exportedAt)     } -    static func plan(_ payload: BackupV11Payload) -> BackupImportPlan {+    static func plan(_ payload: BackupV12Payload) -> BackupImportPlan {         BackupImportPlan(             metadata: BackupImportMetadata(-                formatVersion: BackupV11Document.formatVersion,-                schemaVersion: BackupV11Document.schemaVersion,-                appBuild: "test-11", exportedAt: created,+                formatVersion: BackupV12Document.formatVersion,+                schemaVersion: BackupV12Document.schemaVersion,+                appBuild: "test-12", exportedAt: created,                 capabilityGate: "multi-site", entryCount: payload.entries.count,                 workCount: payload.works.count),             payload: payload,@@ -847,17 +922,17 @@ enum BackupV11Fixtures {      // MARK: - A refused envelope -    /// A 10/11 envelope, hand-written because nothing in the app can mint one any-    /// more. Structurally valid JSON with a well-formed payload: what makes it-    /// unimportable is the version pair, which is exactly the distinction the-    /// refusal has to draw (`work-creators` Req 9.1).+    /// An 11/12 envelope, hand-written because nothing in the app can mint one+    /// any more. Structurally valid JSON with a well-formed payload: what makes+    /// it unimportable is the version pair, which is exactly the distinction the+    /// refusal has to draw (`place-extraction` Req 5.1, Q51).     ///-    /// 10/11 is the generation immediately behind this one, and the one a reader-    /// is most likely to still hold: an archive exported before T-2316 carries-    /// no creator table, no role table and no credits, so the only thing this-    /// build could do with one is invent the absence of every credit the reader-    /// entered.-    static func retiredGenerationDocument(format: Int = 10, schema: Int = 11) -> Data {+    /// 11/12 is the generation immediately behind this one, and the one a reader+    /// is most likely to still hold: an archive exported before T-2276 carries+    /// no place table and no place suppressions, so the only thing this build+    /// could do with one is invent the absence of every place the reader+    /// accepted.+    static func retiredGenerationDocument(format: Int = 11, schema: Int = 12) -> Data {         let payload = #"{"entries":[],"sites":[],"titlePatterns":[],"urlRules":[],"works":[]}"#         let checksum = SHA256.hash(data: Data(payload.utf8))             .map { String(format: "%02x", $0) }.joined()@@ -870,11 +945,11 @@ enum BackupV11Fixtures {      // MARK: - Copies of the frozen records -    /// The composed Entry with a note and its covered revision. `BackupV11Entry`'s+    /// The composed Entry with a note and its covered revision. `BackupV12Entry`'s     /// fields are `let`, so a copy is a full restatement — stated once here     /// rather than in each suite.-    private static func noted(_ record: BackupV11Entry, fingerprint: String?) -> BackupV11Entry {-        BackupV11Entry(+    private static func noted(_ record: BackupV12Entry, fingerprint: String?) -> BackupV12Entry {+        BackupV12Entry(             id: record.id, captureTitle: record.captureTitle,             captureTitleSource: record.captureTitleSource, rawURL: record.rawURL,             canonicalURL: record.canonicalURL, hostname: record.hostname,@@ -892,8 +967,8 @@ enum BackupV11Fixtures {     }      /// The composed Work with generic notes and its covered revision.-    private static func annotated(_ record: BackupV11Work, fingerprint: String?) -> BackupV11Work {-        BackupV11Work(+    private static func annotated(_ record: BackupV12Work, fingerprint: String?) -> BackupV12Work {+        BackupV12Work(             id: record.id, displayTitle: record.displayTitle,             lastParsedTitle: record.lastParsedTitle, genericNotes: genericNotes,             genreTags: record.genreTags, titleProvenance: record.titleProvenance,
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swiftindex 76f7234..3d0d075 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapActionTests.swift@@ -125,7 +125,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() == "12",+        #expect(try root.markerText() == "13",                 "a crash between store creation and the marker is repaired, not terminal")         withExtendedLifetime(root) {}     }@@ -269,7 +269,7 @@ struct BootstrapActionTests {         // the owner's library (Decision 5) — and the container construction is         // what fails.         try Data("this is not a sqlite store".utf8).write(to: root.storeURL, options: .atomic)-        try root.writeMarker("12\n")+        try root.writeMarker("13\n")         let before = try root.digest()          await #expect(throws: (any Error).self) {@@ -309,10 +309,10 @@ private enum RefusedState: String, CaseIterable, Sendable {         switch self {         case .storeRecordedBelowV5:             try root.installStoreRecordedAtFourZeroZero()-            try root.writeMarker("12\n")+            try root.writeMarker("13\n")         case .readinessMarkerWithoutAStore:             try root.createStoreDirectory()-            try root.writeMarker("12\n")+            try root.writeMarker("13\n")         case .historicalMarkerWithoutAStore:             try root.createStoreDirectory()             try root.writeHistoricalMarker()
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift Modified +13 / -13
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swiftindex 20b5b50..8a19781 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapClassifierTests.swift@@ -18,7 +18,7 @@ import Testing /// | Axis | Values | /// |---|---| /// | Store family | absent / main file only / companions only / full family |-/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"10"` / `"11"` / `"12"` / unrecognised text / non-UTF-8 bytes |+/// | Readiness marker | absent / `"4"` / `"5"` / `"6"` / `"11"` / `"12"` / `"13"` / unrecognised text / non-UTF-8 bytes | /// | Historical marker | present / absent | /// | Migration artefact | present / absent | /// | Recorded version | at-or-above V5 / below / indeterminate |@@ -88,11 +88,11 @@ struct BootstrapClassifierTests {      /// `bothMarkersV4Governs` as a classification: the historical marker is a     /// leftover, and the row that matches first wins.-    @Test("A \"12\" marker beside a stale historical marker classifies ready")+    @Test("A \"13\" marker beside a stale historical marker classifies ready")     func readyMarkerGovernsOverAHistoricalMarker() throws {         let root = try ClassifierRoot()         try root.seedBornAtLiveStore()-        try root.writeMarker("12\n")+        try root.writeMarker("13\n")         try root.writeHistoricalMarker()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -102,11 +102,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 \"12\" marker beside a leftover migration artefact classifies ready")+    @Test("A \"13\" marker beside a leftover migration artefact classifies ready")     func readyMarkerGovernsOverALeftoverArtefact() throws {         let root = try ClassifierRoot()         try root.seedBornAtLiveStore()-        try root.writeMarker("12\n")+        try root.writeMarker("13\n")         try root.writeMigrationArtefact()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)@@ -213,7 +213,7 @@ struct BootstrapClassifierTests {         let root = try ClassifierRoot()         try root.createStoreDirectory()         switch kind {-        case .readinessMarker: try root.writeMarker("11\n")+        case .readinessMarker: try root.writeMarker("12\n")         case .historicalMarker: try root.writeHistoricalMarker()         case .migrationSidecar: try root.writeMigrationArtefact()         }@@ -229,7 +229,7 @@ struct BootstrapClassifierTests {     func overlappingOrphanedEvidenceNamesTheReadinessMarker() throws {         let root = try ClassifierRoot()         try root.createStoreDirectory()-        try root.writeMarker("11\n")+        try root.writeMarker("12\n")         try root.writeHistoricalMarker()         try root.writeMigrationArtefact() @@ -345,7 +345,7 @@ struct BootstrapClassifierTests {     func belowV5StoreIsRefused() throws {         let root = try ClassifierRoot()         try V4RecordedStoreFixture.install(at: root.storeURL)-        try root.writeMarker("11\n")+        try root.writeMarker("12\n")          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)                 == .belowV5(version: "4.0.0"),@@ -360,7 +360,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("12\n")+        try root.writeMarker("13\n")         try #require(StoreMetadata.recordedVersion(at: root.storeURL) == .indeterminate)          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready,@@ -426,9 +426,9 @@ 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 .ten: try root.writeMarker("10\n")         case .eleven: try root.writeMarker("11\n")         case .twelve: try root.writeMarker("12\n")+        case .thirteen: try root.writeMarker("13\n")         case .unrecognisedText: try root.writeMarker("99\n")         case .nonUTF8: try root.writeMarkerBytes(ClassifierRoot.nonUTF8MarkerBytes)         }@@ -441,8 +441,8 @@ private struct Cell: Sendable, CustomStringConvertible {     func expectedState(recordedVersion: StoreMetadata.RecordedVersion) -> BootstrapState {         if case .below(let version) = recordedVersion { return .belowV5(version: version) }         let storePresent = family.isStorePresent-        if marker == .twelve, storePresent { return .ready }-        if marker == .eleven, storePresent { return .markerLagging(generation: "11") }+        if marker == .thirteen, storePresent { return .ready }+        if marker == .twelve, storePresent { return .markerLagging(generation: "12") }         if !storePresent {             if marker != .absent { return .orphanedEvidence(kind: .readinessMarker) }             if historicalMarker { return .orphanedEvidence(kind: .historicalMarker) }@@ -469,7 +469,7 @@ private enum StoreFamily: String, CaseIterable, Sendable { }  private enum MarkerAxis: String, CaseIterable, Sendable {-    case absent, four, five, six, ten, eleven, twelve, unrecognisedText, nonUTF8+    case absent, four, five, six, eleven, twelve, thirteen, unrecognisedText, nonUTF8 }  /// What the seeded main file is meant to record. The expectation is derived from
Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift Modified +12 / -11
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swiftindex 33b1da2..cf081c2 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/BootstrapStateCoverageTests.swift@@ -57,9 +57,9 @@ struct AppBootstrapStateTests {      /// The ordered match of the design's state table: evidence overlaps, and the     /// first matching predicate wins. A stale historical marker beside a valid-    /// `"12"` marker is a *ready* library with a leftover, not an ambiguous+    /// `"13"` marker is a *ready* library with a leftover, not an ambiguous     /// state — and the leftover goes after the open, never before it.-    @Test("A stale historical marker beside a \"12\" marker resolves to ready and is cleared")+    @Test("A stale historical marker beside a \"13\" marker resolves to ready and is cleared")     func readyMarkerGovernsOverAHistoricalMarker() async throws {         let root = try LibraryRoot()         try await root.seedReadyLibrary(hostname: "b.example")@@ -183,7 +183,7 @@ struct ExtensionBootstrapStateTests {         #expect(result == .ready(oneSite))     } -    /// Every state the containing app has not brought to a `"12"` marker, with the+    /// Every state the containing app has not brought to a `"13"` 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.@@ -232,15 +232,16 @@ private enum PreCertificationState: String, CaseIterable, Sendable {     case storeWithRetiredMarkerSix     /// `configurable-work-types` Req 8.7's update window, with the **live**     /// generation: the app has been updated and not yet launched, so the library-    /// still records `"11"`. The app opens it — the V11 → V12 stage adds three-    /// empty tables on the way in — validates and republishes at `"12"`; the+    /// still records `"12"`. The app opens it — the V12 → V13 stage adds two+    /// empty tables on the way in — validates and republishes at `"13"`; the     /// extension must not, because it holds only a shared lock and must never     /// migrate. The case name is historical: the lagging row holds one-    /// generation at a time, and it has been substituted four times since —+    /// generation at a time, and it has been substituted five times since —     /// `"8"` for `"7"` (Q2 of `drop-superseded-columns`), `"9"` for `"8"` (Q18     /// of `work-and-reading-status`), `"10"` for `"9"` (Q32 of-    /// `series-and-related-works`) and `"11"` for `"10"` (Q15 of-    /// `work-creators`), which is why the seed below writes `"11"`.+    /// `series-and-related-works`), `"11"` for `"10"` (Q15 of+    /// `work-creators`) and `"12"` for `"11"` (Q48 of `place-extraction`),+    /// which is why the seed below writes `"12"`.     case storeWithLaggingMarkerSeven      func seed(into root: LibraryRoot) async throws {@@ -266,7 +267,7 @@ private enum PreCertificationState: String, CaseIterable, Sendable {         case .storeWithRetiredMarkerSix:             try root.writeMarker("6\n")         case .storeWithLaggingMarkerSeven:-            try root.writeMarker("11\n")+            try root.writeMarker("12\n")         }     } }@@ -276,7 +277,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 = "12\n"+private let readyMarkerBytes = "13\n"  /// The counts of a library seeded with exactly one `Site`. ///@@ -321,7 +322,7 @@ private final class LibraryRoot {     // MARK: - Seeding      /// The state Req 2.2 is about, reached the way the app reaches it: the-    /// app-role opener creates the store, certifies it and marks it `"12"`, and one+    /// app-role opener creates the store, certifies it and marks it `"13"`, and one     /// row is written through the repository it returns. No container opener and     /// no migration path is involved, so nothing here is removed by a later task.     func seedReadyLibrary(hostname: String) async throws {
Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift Modified +17 / -17
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swiftindex 0ec9d7b..73274f5 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CertificationPathTests.swift@@ -16,7 +16,7 @@ import Testing /// /// What is left is the two ends: a store on a retired generation is **refused /// before a container converts it**, and an empty store is marked at birth. The-/// refusal case seeds through `V11RecordedStoreFixture` — a store the container+/// refusal case seeds through `V12RecordedStoreFixture` — a store the container /// *would* open — because a store that could not be opened anyway would prove /// nothing about when the refusal happens. @Suite("Certification paths", .serialized)@@ -44,8 +44,8 @@ struct CertificationPathTests {             .trimmingCharacters(in: .whitespacesAndNewlines)     } -    /// A store recorded at 11.0.0 — the state every installed device is in on-    /// the morning of the V12 update, and one the declared V11 → V12 stage+    /// A store recorded at 12.0.0 — the state every installed device is in on+    /// the morning of the V13 update, and one the declared V12 → V13 stage     /// converts happily. That it *is* convertible is the point: the refusal     /// below has to come from the marker, before any container exists, not from     /// a store nothing could open.@@ -54,10 +54,10 @@ struct CertificationPathTests {     /// each newly declared stage refuses the generation below it outright and     /// the shipped classifier already refused one before any container existed     /// (Req 2.9, Decision 1).-    private func installStoreArrivedAtV11(_ configuration: LibraryConfiguration) throws {-        try V11RecordedStoreFixture.install(at: configuration.storeURL)+    private func installStoreArrivedAtV12(_ configuration: LibraryConfiguration) throws {+        try V12RecordedStoreFixture.install(at: configuration.storeURL)         #expect(try V4RecordedStoreFixture.recordedModelVersions(at: configuration.storeURL)-                == ["11.0.0"], "the seed is written by the frozen snapshot, not the live classes")+                == ["12.0.0"], "the seed is written by the frozen snapshot, not the live classes")     }      // MARK: - The retired generations@@ -72,20 +72,20 @@ struct CertificationPathTests {      /// The refusal happens in `classify`, before `ModelContainer.init`. That is     /// what the recorded version proves: this store would have been converted to-    /// 12.0.0 by any container construction, and it is still recorded at 11.0.0+    /// 13.0.0 by any container construction, and it is still recorded at 12.0.0     /// afterwards. The marker is left alone for the same reason: the recovery is     /// a backup archive restored over this library, and a refusal that rewrote     /// the evidence would take that away.     ///-    /// The 11.0.0 pin only means something alongside the control that follows-    /// it: the same store, marked `"11"`, opens and is recorded 12.0.0. That is+    /// The 12.0.0 pin only means something alongside the control that follows+    /// it: the same store, marked `"12"`, opens and is recorded 13.0.0. That is     /// what makes the pin an ordering claim rather than a store nothing could     /// convert.     @Test("A store on a retired marker generation is refused before anything converts it",-          arguments: ["4", "5", "6", "7", "8", "9", "10"])+          arguments: ["4", "5", "6", "7", "8", "9", "10", "11"])     func retiredMarkerGenerationIsRefusedBeforeConversion(digit: String) async throws {         let (dir, cfg) = try config()-        try installStoreArrivedAtV11(cfg)+        try installStoreArrivedAtV12(cfg)         try Data("\(digit)\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)          do {@@ -97,17 +97,17 @@ struct CertificationPathTests {         }          #expect(try markerContent(cfg) == digit, "a refused open may not rewrite the marker")-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["11.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["12.0.0"],                 "the marker check must decide before ModelContainer.init converts anything")          // Control, mirroring the extension-side twin         // (`MarkerContractTests.extensionDeclinesBeforeOpeningAContainer`):-        // with an `"11"` marker the same store is reached, opened and converted.-        // Without it the 11.0.0 assertion above could hold because the store was+        // with a `"12"` marker the same store is reached, opened and converted.+        // Without it the 12.0.0 assertion above could hold because the store was         // unopenable rather than because the marker was read first.-        try Data("11\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+        try Data("12\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)         _ = try await LibraryRepository.openForApp(cfg)-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["12.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["13.0.0"],                 "the same store converts once the marker check passes")         withExtendedLifetime(dir) {}     }@@ -119,7 +119,7 @@ struct CertificationPathTests {         let (dir, cfg) = try config()         let (result, _) = try await LibraryRepository.openForApp(cfg)         #expect(result == .ready(.zero))-        #expect(try markerContent(cfg) == "12",+        #expect(try markerContent(cfg) == "13",                 "an empty store has nothing to bring forward and is certified at birth (Q26)")         withExtendedLifetime(dir) {}     }
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift Modified +19 / -18
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swiftindex 48b67ea..0fbc30f 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift@@ -110,13 +110,13 @@ struct CharacterDuplicateSetTests {         let fixture = try await seeded(characters: [             M5SeedCharacter(                 id: Self.hanna, name: "Hanna",-                facts: [CharacterFact(+                facts: [RecordFact(                     statement: "Leads the squad", quote: "she led", nameKey: "hanna",                     source: .entry(entry))],                 workID: Self.workID),             M5SeedCharacter(                 id: Self.hanna, name: "Hanna",-                facts: [CharacterFact(+                facts: [RecordFact(                     statement: "She led it", quote: "she led", nameKey: "hanna",                     source: .entry(entry))],                 workID: Self.workID),@@ -131,9 +131,9 @@ struct CharacterDuplicateSetTests {     /// 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(+        let one = RecordFact(             statement: "A", quote: "qa", nameKey: "hanna", source: .genericNotes)-        let other = CharacterFact(+        let other = RecordFact(             statement: "B", quote: "qb", nameKey: "hanna",             source: .entry(UUID(uuidString: "0E000000-0000-4000-8000-0000000000E2")!))         let fixture = try await seeded(characters: [@@ -148,12 +148,12 @@ struct CharacterDuplicateSetTests {         withExtendedLifetime(fixture) {}     } -    /// `CharacterAuthoredContent` is never bare, so a "bare" arrival cannot+    /// `RecordAuthoredContent` 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)+        #expect(!RecordAuthoredContent.bare.isBare)+        #expect(!RecordAuthoredContent(name: "Hanna").isBare)     }      /// Req 6.7: a character whose work has not arrived is inert, not a set and@@ -172,10 +172,10 @@ struct CharacterDuplicateSetTests {     }      /// 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)+    /// stylistic choice. `.character` still sorts after every pre-existing type;+    /// V13's `.place` was appended after it (`PlaceDuplicateSetTests`).+    @Test("`.character` sorts after every type that predates it")+    func characterIsAppendedAfterTheOlderTypes() {         #expect(DuplicateRecordType.entry < DuplicateRecordType.character)         #expect(DuplicateRecordType.urlRule < DuplicateRecordType.character)     }@@ -296,8 +296,8 @@ struct CharacterConvergenceTests {             M5SeedCharacter(id: Self.hanna, name: "Hanna", note: "reckless", workID: Self.workID),         ]) -        await #expect(throws: BackupV11ExportError.self) {-            _ = try await fixture.repository.backupV11Snapshot()+        await #expect(throws: BackupV12ExportError.self) {+            _ = try await fixture.repository.backupV12Snapshot()         }         withExtendedLifetime(fixture) {}     }@@ -311,8 +311,8 @@ struct CharacterCitationRepointingTests {     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)+    private func fact(_ source: SourceRef) -> RecordFact {+        RecordFact(statement: "Leads", quote: "she led", nameKey: "hanna", source: source)     }      /// The whole point of Q85: rewriting one row of a group changes its authored@@ -379,7 +379,7 @@ struct CharacterCitationRepointingTests {      /// The reconciler derives the stamp from the collapsing Entries rather than     /// a clock (Q56), so it is routinely *older* than the character it rewrites.-    /// `CharacterGroup.modifiedAt` is what `BackupV11Character` carries as its+    /// `CharacterGroup.modifiedAt` is what `BackupV12Character` 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")@@ -422,8 +422,9 @@ private extension LibraryRepository {         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 CitationRepointing.repoint(+                CharacterRecord.self, CharacterSuppression.self,+                survivors: [loser: survivor], in: works, context: context, timestamp: timestamp)             try context.save()         }     }
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift Modified +658 / -186
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swiftindex d93f2db..ef26c49 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift@@ -4,9 +4,15 @@ 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).+// The edit-mode half (Req 3.2, 3.3, 3.4, 3.7, 5.3) over **both record kinds**:+// one repository call per session, staged operations in the order performed, a+// whole-step refusal on any basis mismatch (Q73/Q97), and Decision 1's+// conversion applied inside that same save.+//+// Everything the character step did, a place step does — so the suites are+// parameterised over `RecordKind` and say it once. The arms that are new are the+// ones only two kinds can have: conversion, and an operation whose kinds+// disagree.  private let epoch = M5Fixture.epoch private let workA = UUID(uuidString: "1A000000-0000-4000-8000-00000000000A")!@@ -15,22 +21,27 @@ 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 let alexTen = UUID(uuidString: "1A000000-0000-4000-8000-000000000203")!+private let lighthouse = UUID(uuidString: "1A000000-0000-4000-8000-000000000204")!  private func fact(     _ statement: String, _ quote: String, _ source: SourceRef, key: String-) -> CharacterFact {-    CharacterFact(statement: statement, quote: quote, nameKey: key, source: source)+) -> RecordFact {+    RecordFact(statement: statement, quote: quote, nameKey: key, source: source) } -private func basis(_ id: UUID, _ rows: [CharacterAuthoredContent]) throws -> CharacterEditBasis {+private func basis(+    _ kind: RecordKind, _ id: UUID, _ rows: [RecordAuthoredContent]+) throws -> RecordEditBasis {     let content = try #require(rows.first)-    return CharacterEditBasis(characterID: id, content: content)+    return RecordEditBasis(kind: kind, recordID: id, content: content) } -private func seeded(-    characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []-) async throws -> M5Fixture {-    let fixture = try await M5Fixture()+/// The kind a conversion goes to.+private func other(_ kind: RecordKind) -> RecordKind {+    kind == .character ? .place : .character+}++private func seedWork(_ fixture: M5Fixture) async throws {     try await fixture.repository.seedM5Rows(         sites: [M5SeedSite(hostname: "c.example")],         works: [M5SeedWork(@@ -38,57 +49,75 @@ private func seeded(             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)+            note: "Alex is Terawatt", workID: workA)])+}++private func seeded(+    _ kind: RecordKind = .character,+    records: [M5SeedRecord] = [], suppressions: [M5SeedRecordSuppression] = []+) async throws -> M5Fixture {+    let fixture = try await M5Fixture()+    try await seedWork(fixture)+    try await fixture.repository.seedM5Records(+        kind, records: records, suppressions: suppressions)     return fixture } -@Suite("The character edit step (Q73, Q97)", .serialized)-struct CharacterEditStepTests {+@Suite("The record edit step (Q73, Q97)", .serialized)+struct RecordEditStepTests { -    @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),+    @Test(+        "Hand-creation mints the key from the typed name and clears its suppression",+        arguments: RecordKind.allCases)+    func creationMintsAndClears(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, suppressions: [+            M5SeedRecordSuppression(+                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"))])+        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.create(RecordDraft(kind: kind, name: "The Alex", note: "hand"))])         guard case .committed(let ids) = outcome, let id = ids.first else {-            Issue.record("expected a created character, got \(outcome)")+            Issue.record("expected a created record, got \(outcome)")             return         } -        let rows = try await fixture.repository.m5CharacterRows(id: id)+        let rows = try await fixture.repository.m5RecordRows(kind, 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,+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.match(nameKey: "alex", kind: kind)?.id == id,                 "the key is minted from the typed name at commit (Q46)")-        #expect(candidate.suppressions.candidateKeys.isEmpty, "Q44: creation clears the key")+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty,+                "Q44/Req 3.2: creation clears the key, under its own kind")+        #expect(candidate.suppressions(of: other(kind)).candidateKeys.isEmpty,+                "and the other kind's table was never written to (Q13)")         withExtendedLifetime(fixture) {}     } -    @Test("An edit writes name, note and fact statements to every row of the group")-    func editFansOutAcrossTheGroup() async throws {+    @Test(+        "An edit writes name, note and fact statements to every row of the group",+        arguments: RecordKind.allCases)+    func editFansOutAcrossTheGroup(kind: RecordKind) 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 fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),         ])-        let rows = try await fixture.repository.m5CharacterRows(id: alex)+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)         var edited = stored         edited.statement = "Leads the team" -        let outcome = try await fixture.repository.commitCharacterEdits(+        let outcome = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.update(-                basis: try basis(alex, rows),-                draft: CharacterDraft(name: "Alexandra", note: "renamed", facts: [edited]))])-        #expect(outcome == .committed(characterIDs: [alex]))+                basis: try basis(kind, alex, rows),+                draft: RecordDraft(+                    kind: kind, name: "Alexandra", note: "renamed", facts: [edited]))])+        #expect(outcome == .committed(recordIDs: [alex])) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, 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"] })@@ -98,102 +127,113 @@ struct CharacterEditStepTests {      /// 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 {+    @Test(+        "A draft cannot author a new quote, and dropping a fact suppresses its triple",+        arguments: RecordKind.allCases)+    func factDropSuppresses(kind: RecordKind) 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 fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", facts: [stored], workID: workA),         ])-        let rows = try await fixture.repository.m5CharacterRows(id: alex)+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex) -        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.update(-                basis: try basis(alex, rows),-                draft: CharacterDraft(-                    name: "Alex",+                basis: try basis(kind, alex, rows),+                draft: RecordDraft(+                    kind: kind, name: "Alex",                     facts: [fact("Invented", "a quote nobody wrote", .genericNotes, key: "alex")]))]) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, 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])+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.suppressions(of: kind).factIdentities == [stored.identity])+        #expect(candidate.suppressions(of: other(kind)).factIdentities.isEmpty,+                "a fact decision is about one kind's record (Q13)")         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 {+    /// Q50: a rename-then-delete would otherwise re-propose the record under the+    /// deleted name, and aliases own absorbed names' routing.+    @Test(+        "Deleting suppresses the retained, current and alias keys and every fact",+        arguments: RecordKind.allCases)+    func deletionSuppressesEveryKey(kind: RecordKind) async throws {         let stored = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")-        let fixture = try await seeded(characters: [-            M5SeedCharacter(+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(                 id: alex, name: "Alexandra", nameKey: "alex", aliases: ["Terawatt"],                 facts: [stored], workID: workA),         ])-        let rows = try await fixture.repository.m5CharacterRows(id: alex)+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex) -        let outcome = try await fixture.repository.commitCharacterEdits(-            workID: workA, operations: [.delete(basis: try basis(alex, rows))])-        #expect(outcome == .committed(characterIDs: [alex]))+        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA, operations: [.delete(basis: try basis(kind, alex, rows))])+        #expect(outcome == .committed(recordIDs: [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])+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: kind).isEmpty)+        #expect(candidate.suppressions(of: kind).candidateKeys+                == ["alex", "alexandra", "terawatt"])+        #expect(candidate.suppressions(of: kind).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),+    /// Q73: partial 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 record",+        arguments: RecordKind.allCases)+    func basisMismatchRefusesTheWholeStep(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),         ])-        let stale = CharacterEditBasis(-            characterID: alex, name: "Somebody Else", note: "", aliases: [], facts: [])+        let stale = RecordEditBasis(+            kind: kind, recordID: alex, name: "Somebody Else", note: "", aliases: [], facts: []) -        let outcome = try await fixture.repository.commitCharacterEdits(+        let outcome = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [-                .create(CharacterDraft(name: "Bruce")),-                .update(basis: stale, draft: CharacterDraft(name: "Nope")),+                .create(RecordDraft(kind: kind, name: "Bruce")),+                .update(basis: stale, draft: RecordDraft(kind: kind, name: "Nope")),             ])-        #expect(outcome == .refused(.basisMismatch(characterID: alex, name: "Somebody Else")))+        #expect(outcome == .refused(.basisMismatch(recordID: alex, name: "Somebody Else")))          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 10).first)-        #expect(candidate.characters.map(\.id) == [alex],+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: kind).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),+    @Test(+        "A torn record refuses the step rather than being written over",+        arguments: RecordKind.allCases)+    func tornRecordRefuses(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", note: "one", workID: workA),+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", note: "two", workID: workA),         ])-        let stale = CharacterEditBasis(-            characterID: alex, name: "Alex", note: "one", aliases: [], facts: [])+        let stale = RecordEditBasis(+            kind: kind, recordID: alex, name: "Alex", note: "one", aliases: [], facts: []) -        let outcome = try await fixture.repository.commitCharacterEdits(+        let outcome = try await fixture.repository.commitRecordEdits(             workID: workA,-            operations: [.update(basis: stale, draft: CharacterDraft(name: "Alex", note: "three"))])-        #expect(outcome == .refused(.torn(characterID: alex, name: "Alex")))+            operations: [.update(+                basis: stale, draft: RecordDraft(kind: kind, name: "Alex", note: "three"))])+        #expect(outcome == .refused(.torn(recordID: 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 {+    /// 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. Nothing of the step is written, the create included.+    @Test("A torn work refuses the whole edit step", arguments: RecordKind.allCases)+    func tornWorkRefusesTheStep(kind: RecordKind) async throws {         let fixture = try await M5Fixture()         try await fixture.repository.seedM5Rows(             sites: [M5SeedSite(hostname: "c.example")],@@ -206,98 +246,154 @@ struct CharacterEditStepTests {                     genericNotes: "two"),             ]) -        let outcome = try await fixture.repository.commitCharacterEdits(-            workID: workA, operations: [.create(CharacterDraft(name: "Alex"))])+        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA, operations: [.create(RecordDraft(kind: kind, name: "Alex"))])          #expect(outcome == .refused(.workTorn))         #expect(try await fixture.repository.m5AllCharacters().isEmpty)+        #expect(try await fixture.repository.m5AllPlaces().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),+    @Test("Staged operations apply in the order performed", arguments: RecordKind.allCases)+    func operationsApplyInOrder(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedRecord(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 alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let terawattRows = try await fixture.repository.m5RecordRows(kind, id: terawatt) -        let outcome = try await fixture.repository.commitCharacterEdits(+        let outcome = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [                 .combine(-                    source: try basis(terawatt, terawattRows),-                    target: try basis(alex, alexRows)),+                    source: try basis(kind, terawatt, terawattRows),+                    target: try basis(kind, alex, alexRows)),                 .update(-                    basis: CharacterEditBasis(-                        characterID: alex, name: "Alex", note: "", aliases: ["Terawatt"],-                        facts: []),-                    draft: CharacterDraft(-                        name: "Alex", note: "after the combine", aliases: ["Terawatt"])),+                    basis: RecordEditBasis(+                        kind: kind, recordID: alex, name: "Alex", note: "",+                        aliases: ["Terawatt"], facts: []),+                    draft: RecordDraft(+                        kind: kind, name: "Alex", note: "after the combine",+                        aliases: ["Terawatt"])),             ])-        #expect(outcome == .committed(characterIDs: [alex, alex]))+        #expect(outcome == .committed(recordIDs: [alex, alex])) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, id: alex)         #expect(after.first?.note == "after the combine")         #expect(after.first?.aliases == ["Terawatt"])         withExtendedLifetime(fixture) {}     }++    /// Q47: one call carries both kinds' operations, applied in the order the+    /// reader performed them and committed by **one** save — two calls could not+    /// make a mixed session atomic.+    @Test("A mixed-kind operation list applies in order and saves once")+    func mixedKindOperationsApplyInOrderAndSaveOnce() async throws {+        let saves = InstrumentedSaveStrategy()+        let fixture = try await M5Fixture(saveStrategy: saves)+        try await seedWork(fixture)+        try await fixture.repository.seedM5Records(.character, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        try await fixture.repository.seedM5Records(.place, records: [+            M5SeedRecord(+                id: lighthouse, name: "Lighthouse", nameKey: "lighthouse", workID: workA),+        ])+        let alexRows = try await fixture.repository.m5RecordRows(.character, id: alex)+        let lighthouseRows = try await fixture.repository.m5RecordRows(.place, id: lighthouse)+        let before = saves.saveCount++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [+                .update(+                    basis: try basis(.place, lighthouse, lighthouseRows),+                    draft: RecordDraft(+                        kind: .place, name: "The Lighthouse", note: "on the point")),+                .create(RecordDraft(kind: .character, name: "Bruce")),+                .update(+                    basis: try basis(.character, alex, alexRows),+                    draft: RecordDraft(kind: .character, name: "Alexandra")),+            ])+        guard case .committed(let ids) = outcome else {+            Issue.record("expected a committed step, got \(outcome)")+            return+        }+        #expect(ids.first == lighthouse && ids.last == alex, "in the order performed (Q97)")+        #expect(saves.saveCount - before == 1, "one step, one save")++        #expect(try await fixture.repository.m5RecordRows(.place, id: lighthouse)+                .first?.name == "The Lighthouse")+        #expect(try await fixture.repository.m5RecordRows(.character, id: alex)+                .first?.name == "Alexandra")+        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: .character).count == 2, "Alexandra and the new Bruce")+        #expect(candidate.records(of: .place).count == 1)+        withExtendedLifetime(fixture) {}+    } } -@Suite("Combining characters (Decision 4)", .serialized)-struct CharacterCombineTests {+@Suite("Combining records (Decision 4)", .serialized)+struct RecordCombineTests {      /// 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(+    @Test(+        "The alias union carries the source's current name, retained key and aliases",+        arguments: RecordKind.allCases)+    func aliasUnionCarriesEveryMatchKey(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedRecord(                 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)+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let terawattRows = try await fixture.repository.m5RecordRows(kind, id: terawatt) -        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.combine(-                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])+                source: try basis(kind, terawatt, terawattRows),+                target: try basis(kind, alex, alexRows))]) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, 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")+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: kind).map(\.id) == [alex])+        #expect(candidate.match(nameKey: "terrawatt", kind: kind)?.id == alex)+        #expect(candidate.match(nameKey: "terawatt", kind: kind)?.id == alex,+                "a later proposal under the absorbed name routes to the combined record")         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 {+    @Test(+        "Facts move re-keyed; duplicates drop but edited-apart copies both survive",+        arguments: RecordKind.allCases)+    func factsMoveAndDedup(kind: RecordKind) async throws {         let shared = "Alex is Terawatt"-        let fixture = try await seeded(characters: [-            M5SeedCharacter(+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(                 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(+            M5SeedRecord(                 id: terawatt, name: "Terawatt", nameKey: "terawatt",                 facts: [                     // Same source and quote: re-keyed, this is the same triple.@@ -307,15 +403,16 @@ struct CharacterCombineTests {                 ],                 workID: workA),         ])-        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)-        let terawattRows = try await fixture.repository.m5CharacterRows(id: terawatt)+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let terawattRows = try await fixture.repository.m5RecordRows(kind, id: terawatt) -        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.combine(-                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])+                source: try basis(kind, terawatt, terawattRows),+                target: try basis(kind, alex, alexRows))]) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, 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)")@@ -326,33 +423,36 @@ struct CharacterCombineTests {      /// 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 {+    @Test(+        "The source's active fact suppressions re-key to the target",+        arguments: RecordKind.allCases)+    func suppressionsRekey(kind: RecordKind) 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),+            kind,+            records: [+                M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+                M5SeedRecord(id: terawatt, name: "Terawatt", nameKey: "terawatt", workID: workA),             ],             suppressions: [-                M5SeedSuppression(+                M5SeedRecordSuppression(                     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)+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let terawattRows = try await fixture.repository.m5RecordRows(kind, id: terawatt) -        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.combine(-                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])+                source: try basis(kind, terawatt, terawattRows),+                target: try basis(kind, alex, alexRows))])          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 10).first)-        #expect(candidate.suppressions.factIdentities-                == [CharacterFactIdentity(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.suppressions(of: kind).factIdentities+                == [RecordFactIdentity(                     nameKey: "alex", source: .entry(entry1), quote: "a quote")])-        #expect(candidate.suppressions.candidateKeys.isEmpty,+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty,                 """                 Decision 4: a combine writes no new suppressions, or the alias routing \                 it exists for would be fought by the deletion rule@@ -360,55 +460,365 @@ struct CharacterCombineTests {         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(+    @Test(+        "The source's note is appended under a divider and its rows delete whole",+        arguments: RecordKind.allCases)+    func noteAppendsAndSourceDeletes(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", note: "target", workID: workA),+            M5SeedRecord(                 id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "source",                 workID: workA),-            M5SeedCharacter(+            M5SeedRecord(                 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)+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let terawattRows = try await fixture.repository.m5RecordRows(kind, id: terawatt) -        _ = try await fixture.repository.commitCharacterEdits(+        _ = try await fixture.repository.commitRecordEdits(             workID: workA,             operations: [.combine(-                source: try basis(terawatt, terawattRows), target: try basis(alex, alexRows))])+                source: try basis(kind, terawatt, terawattRows),+                target: try basis(kind, alex, alexRows))]) -        let after = try await fixture.repository.m5CharacterRows(id: alex)+        let after = try await fixture.repository.m5RecordRows(kind, id: alex)         #expect(after.first?.note == "target" + CharacterNoteAppend.divider + "source")-        #expect(try await fixture.repository.m5CharacterRows(id: terawatt).isEmpty,+        #expect(try await fixture.repository.m5RecordRows(kind, 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(+    @Test("A torn source or target refuses the combine", arguments: RecordKind.allCases)+    func tornGatesBothSides(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+            M5SeedRecord(                 id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "one", workID: workA),-            M5SeedCharacter(+            M5SeedRecord(                 id: terawatt, name: "Terawatt", nameKey: "terawatt", note: "two", workID: workA),         ])-        let alexRows = try await fixture.repository.m5CharacterRows(id: alex)+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex) -        let outcome = try await fixture.repository.commitCharacterEdits(+        let outcome = try await fixture.repository.commitRecordEdits(             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")))+                source: RecordEditBasis(+                    kind: kind, recordID: terawatt, name: "Terawatt", note: "one",+                    aliases: [], facts: []),+                target: try basis(kind, alex, alexRows))])+        #expect(outcome == .refused(.torn(recordID: terawatt, name: "Terawatt")))         withExtendedLifetime(fixture) {}     } } -@Suite("Characters through work merge, deletion and entry detail (Req 3.4, 5.4)", .serialized)+@Suite("Converting a record between kinds (Req 3.7, Decision 1)", .serialized)+struct RecordConversionTests {++    /// Decision 1: delete-and-recreate under a **new** UUID — an application+    /// identity means nothing across two entities — carrying the content the+    /// draft holds at Save (Q54) and the original's retained key (Q34).+    @Test(+        "Conversion recreates the record under the other kind with a new identity",+        arguments: RecordKind.allCases)+    func conversionCarriesContentAndRetainedKey(kind: RecordKind) async throws {+        let stored = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(+                id: alex, name: "Alexandra", nameKey: "alex", aliases: ["Terawatt"],+                facts: [stored], workID: workA),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: try basis(kind, alex, rows), to: destination,+                draft: RecordDraft(+                    kind: destination, name: "Alexandra", note: "converted",+                    aliases: ["Terawatt"], facts: [stored]))])+        guard case .committed(let ids) = outcome, let created = ids.first else {+            Issue.record("expected a committed conversion, got \(outcome)")+            return+        }+        #expect(created != alex, "a new identity, never the original's UUID")++        #expect(try await fixture.repository.m5RecordRows(kind, id: alex).isEmpty,+                "the original is deleted in the same save")+        let after = try await fixture.repository.m5RecordRows(destination, id: created)+        #expect(after.count == 1)+        #expect(after.first?.name == "Alexandra")+        #expect(after.first?.note == "converted")+        #expect(after.first?.aliases == ["Terawatt"])+        #expect(after.first?.facts.map(\.statement) == ["Leads"],+                "the facts, their citations and their evidence spans move across")+        #expect(after.first?.facts.first?.quote == "Alex is Terawatt")++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: kind).isEmpty)+        #expect(candidate.records(of: destination).map(\.id) == [created],+                "the new record belongs to the work the original did")+        #expect(candidate.records(of: destination).map(\.retainedKey) == ["alex"],+                "Q34: the retained key carries over, so matching survives the conversion")+        withExtendedLifetime(fixture) {}+    }++    /// Q35: the triples, and **no** name-key suppression — otherwise the model+    /// repeating its original misfiling would be silenced for ever and the+    /// converted record would never receive facts automatically.+    @Test(+        "Conversion suppresses the original's fact triples under the old kind, no name key",+        arguments: RecordKind.allCases)+    func conversionSuppressesTriplesUnderTheOldKind(kind: RecordKind) async throws {+        let carried = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let dropped = fact("Wears blue", "blue coat", .genericNotes, key: "alex")+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(+                id: alex, name: "Alex", nameKey: "alex", facts: [carried, dropped],+                workID: workA),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)++        // The draft as it stands at Save (Q54): the reader dropped one fact+        // before converting, so it never reaches the new record — and Req 3.7's+        // "its deleted facts" covers it just as the delete rule would.+        _ = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: try basis(kind, alex, rows), to: destination,+                draft: RecordDraft(kind: destination, name: "Alex", facts: [carried]))])++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.suppressions(of: kind).factIdentities+                == [carried.identity, dropped.identity],+                "every fact the original held is suppressed under the kind it left")+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty,+                "Q35: no name-key suppression, as a combine's absorption writes none")+        let created = try #require(candidate.records(of: destination).first?.id)+        #expect(try await fixture.repository.m5RecordRows(destination, id: created)+                .first?.facts.map(\.statement) == ["Leads"],+                "the dropped fact does not ride the conversion")+        withExtendedLifetime(fixture) {}+    }++    /// Req 3.7: the new kind's standing suppressions of the original's retained,+    /// current and alias keys, of the draft's keys, and of the carried triples+    /// are cleared — the reader has just said this record belongs here.+    @Test(+        "Conversion clears the destination kind's suppressions of every carried key",+        arguments: RecordKind.allCases)+    func conversionClearsTheDestinationSuppressions(kind: RecordKind) async throws {+        let carried = fact("Leads", "Alex is Terawatt", .entry(entry1), key: "alex")+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(+                id: alex, name: "Alexandra", nameKey: "alex", aliases: ["Terawatt"],+                facts: [carried], workID: workA),+        ])+        try await fixture.repository.seedM5Records(destination, suppressions: [+            M5SeedRecordSuppression(workID: workA, nameKey: "alex"),+            M5SeedRecordSuppression(workID: workA, nameKey: "alexandra"),+            M5SeedRecordSuppression(workID: workA, nameKey: "terawatt"),+            M5SeedRecordSuppression(workID: workA, nameKey: "prime"),+            M5SeedRecordSuppression(workID: workA, nameKey: "tw"),+            M5SeedRecordSuppression(workID: workA, nameKey: "ghost"),+            M5SeedRecordSuppression(+                workID: workA, kind: .fact, nameKey: "alex", source: .entry(entry1),+                evidence: "Alex is Terawatt"),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)++        _ = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: try basis(kind, alex, rows), to: destination,+                draft: RecordDraft(+                    kind: destination, name: "Prime", aliases: ["TW"], facts: [carried]))])++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.suppressions(of: destination).candidateKeys == ["ghost"],+                "the retained, current, alias and drafted keys clear; nothing else does")+        #expect(candidate.suppressions(of: destination).factIdentities.isEmpty,+                "the carried triples clear too, or the facts could never be enriched again")+        withExtendedLifetime(fixture) {}+    }++    /// Q39: a torn record has no single content to carry, which is why+    /// acceptance refuses a torn target too.+    @Test("A torn record refuses conversion", arguments: RecordKind.allCases)+    func tornRecordRefusesConversion(kind: RecordKind) async throws {+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", note: "one", workID: workA),+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", note: "two", workID: workA),+        ])+        let stale = RecordEditBasis(+            kind: kind, recordID: alex, name: "Alex", note: "one", aliases: [], facts: [])++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: stale, to: destination,+                draft: RecordDraft(kind: destination, name: "Alex", note: "one"))])+        #expect(outcome == .refused(.torn(recordID: alex, name: "Alex")))+        #expect(try await fixture.repository.m5AllRecords(destination).isEmpty,+                "nothing of the conversion was written")+        withExtendedLifetime(fixture) {}+    }++    /// The existing rule, over an operation that writes two tables: a mismatch+    /// refuses the whole step, and the row the conversion had already inserted+    /// goes with it.+    @Test(+        "A basis mismatch rolls the whole conversion back",+        arguments: RecordKind.allCases)+    func conversionRollsBackOnBasisMismatch(kind: RecordKind) async throws {+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        let stale = RecordEditBasis(+            kind: kind, recordID: alex, name: "Somebody Else", note: "", aliases: [], facts: [])++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [+                .create(RecordDraft(kind: destination, name: "Bruce")),+                .convert(+                    basis: stale, to: destination,+                    draft: RecordDraft(kind: destination, name: "Alex")),+            ])+        #expect(outcome == .refused(.basisMismatch(recordID: alex, name: "Somebody Else")))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: kind).map(\.id) == [alex], "the original is untouched")+        #expect(try await fixture.repository.m5AllRecords(destination).isEmpty,+                "neither the created row nor the converted one survived the refusal")+        withExtendedLifetime(fixture) {}+    }++    /// The third `.kindMismatch` arm, beside the combine and update ones. A+    /// conversion naming the kind the record already has is not a no-op to wave+    /// through: `.convert` deletes and recreates (Decision 1), so obeying it+    /// would mint a new UUID for a record nothing asked to move.+    @Test(+        "A conversion naming the record's own kind refuses .kindMismatch",+        arguments: RecordKind.allCases)+    func conversionToItsOwnKindRefuses(kind: RecordKind) async throws {+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: try basis(kind, alex, rows), to: kind,+                draft: RecordDraft(kind: kind, name: "Alexandra"))])+        #expect(outcome == .refused(.kindMismatch(recordID: alex)))++        let after = try await fixture.repository.m5RecordRows(kind, id: alex)+        #expect(after.count == 1, "the record is neither deleted nor recreated")+        #expect(after.first?.name == "Alex", "and the draft's rename was not written either")+        #expect(try await fixture.repository.m5AllRecords(other(kind)).isEmpty,+                "nothing reached the other table")+        withExtendedLifetime(fixture) {}+    }++    /// The other absence a conversion can meet: the record went while the+    /// editor sat open, so the basis names a row no group answers for any more.+    /// It refuses rather than minting a destination row for content that is+    /// gone — a conversion is delete-and-recreate (Decision 1), and there is+    /// nothing left to delete.+    @Test(+        "A conversion whose record went under it refuses .recordGone",+        arguments: RecordKind.allCases)+    func conversionOfAGoneRecordRefuses(kind: RecordKind) async throws {+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let captured = try basis(kind, alex, rows)++        // The rows go out from under the open session — the reader's other+        // device deleting the record, arriving as a committed step of its own.+        _ = try await fixture.repository.commitRecordEdits(+            workID: workA, operations: [.delete(basis: captured)])+        #expect(try await fixture.repository.m5RecordRows(kind, id: alex).isEmpty)++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.convert(+                basis: captured, to: destination,+                draft: RecordDraft(kind: destination, name: "Alex"))])+        #expect(outcome == .refused(.recordGone(recordID: alex)))+        #expect(try await fixture.repository.m5AllRecords(kind).isEmpty,+                "nothing came back in the table the record left")+        #expect(try await fixture.repository.m5AllRecords(destination).isEmpty,+                "and nothing was minted in the one it was going to")+        withExtendedLifetime(fixture) {}+    }++    /// Unreachable from the views — the target list is per kind and the editor+    /// knows its own — and pinned here so it stays a refusal rather than a+    /// silent cross-table write.+    @Test("A combine across kinds refuses .kindMismatch", arguments: RecordKind.allCases)+    func combineAcrossKindsRefuses(kind: RecordKind) async throws {+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        try await fixture.repository.seedM5Records(destination, records: [+            M5SeedRecord(+                id: lighthouse, name: "Lighthouse", nameKey: "lighthouse", workID: workA),+        ])+        let alexRows = try await fixture.repository.m5RecordRows(kind, id: alex)+        let lighthouseRows = try await fixture.repository.m5RecordRows(+            destination, id: lighthouse)++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.combine(+                source: try basis(destination, lighthouse, lighthouseRows),+                target: try basis(kind, alex, alexRows))])+        #expect(outcome == .refused(.kindMismatch(recordID: lighthouse)))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 10).first)+        #expect(candidate.records(of: destination).map(\.id) == [lighthouse],+                "nothing was absorbed and nothing was deleted")+        withExtendedLifetime(fixture) {}+    }++    @Test(+        "An update whose draft names the other kind refuses .kindMismatch",+        arguments: RecordKind.allCases)+    func updateAgainstTheOtherKindsBasisRefuses(kind: RecordKind) async throws {+        let destination = other(kind)+        let fixture = try await seeded(kind, records: [+            M5SeedRecord(id: alex, name: "Alex", nameKey: "alex", workID: workA),+        ])+        let rows = try await fixture.repository.m5RecordRows(kind, id: alex)++        let outcome = try await fixture.repository.commitRecordEdits(+            workID: workA,+            operations: [.update(+                basis: try basis(kind, alex, rows),+                draft: RecordDraft(kind: destination, name: "Alexandra"))])+        #expect(outcome == .refused(.kindMismatch(recordID: alex)))+        #expect(try await fixture.repository.m5RecordRows(kind, id: alex).first?.name == "Alex")+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Records through work merge, deletion and entry detail (Req 3.4, 5.4)", .serialized) struct CharacterWorkIntegrationTests {      private func twoWorks() async throws -> M5Fixture {@@ -442,9 +852,9 @@ struct CharacterWorkIntegrationTests {                 workID: workA)],             suppressions: [M5SeedSuppression(workID: workA, nameKey: "ghost")])         // Cover the target's generic notes, so the reset is observable.-        _ = try await fixture.repository.advanceCharacterCoverage(+        _ = try await fixture.repository.advanceCoverage(             workID: workB,-            sources: [CharacterCompletedSource(+            sources: [CompletedSource(                 ref: .genericNotes,                 fingerprint: CharacterCoverageFingerprint.of("target notes"))]) @@ -458,10 +868,10 @@ struct CharacterWorkIntegrationTests {         }          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 10)+            try await fixture.repository.extractionCandidates(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.records(of: .character).map(\.id) == [alex], "the character moved to the target")+        #expect(candidate.suppressions(of: .character).candidateKeys == ["ghost"], "suppressions are unioned")         #expect(candidate.uncoveredSources.contains { $0.ref == .genericNotes },                 "the target's coverage resets so a later sweep revisits it") @@ -559,11 +969,73 @@ struct CharacterWorkIntegrationTests {         withExtendedLifetime(fixture) {}     } -    @Test("An entry nothing cites carries no citing characters")+    /// Req 4.4: a section separate from the characters one, in name order,+    /// populated in the **same** locked read — two surfaces resolving the same+    /// question independently is how they come to disagree.+    ///+    /// The fixture makes the order it is not observably wrong: Ward Bay cites+    /// the entry three times and would lead a ranked list, and "Bay 2"/"Bay 10"+    /// order that way only under `localizedStandardCompare`.+    @Test("Entry detail names the places citing that entry, in name order")+    func entryDetailNamesCitingPlaces() 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")],+                workID: workA)],+            places: [+                M5SeedPlace(+                    id: lighthouse, name: "Ward Bay", nameKey: "ward bay",+                    facts: [+                        fact("Anchors", "Alex is Terawatt", .entry(entry1), key: "ward bay"),+                        fact("Storms", "Alex is", .entry(entry1), key: "ward bay"),+                        fact("Empties", "Terawatt", .entry(entry1), key: "ward bay"),+                        fact("Named", "source notes", .genericNotes, key: "ward bay"),+                    ],+                    workID: workA),+                M5SeedPlace(+                    id: alexTen, name: "Bay 10", nameKey: "bay10",+                    facts: [fact("Passed", "Alex is", .entry(entry1), key: "bay10")],+                    workID: workA),+                M5SeedPlace(+                    id: terawatt, name: "Bay 2", nameKey: "bay2",+                    facts: [fact("Passed", "Alex is", .entry(entry1), key: "bay2")],+                    workID: workA),+                // Another work's place citing the same entry id: not this+                // entry's, because the entry belongs to one work.+                M5SeedPlace(+                    id: UUID(), name: "Elsewhere", nameKey: "elsewhere",+                    facts: [fact("Wrong work", "Alex is", .entry(entry1), key: "elsewhere")],+                    workID: workB),+                // Req 5.5's orphan: a `workID` naming no Work at all. It cites+                // the entry too, and it is reached by no work's read — the+                // tolerated state, displayed nowhere, not an extra row here.+                M5SeedPlace(+                    id: UUID(), name: "Nowhere", nameKey: "nowhere",+                    facts: [fact("Orphan", "Alex is", .entry(entry1), key: "nowhere")],+                    workID: UUID()),+            ])++        let detail = try await fixture.repository.entryTeachingDetail(id: entry1)+        #expect(detail.citingPlaces.map(\.name) == ["Bay 2", "Bay 10", "Ward Bay"],+                "name order, not prominence order and not the ASCII one")+        #expect(detail.citingPlaces.map(\.id) == [terawatt, alexTen, lighthouse])+        #expect(!detail.citingPlaces.map(\.name).contains("Nowhere"),+                "Req 5.5: an orphan belongs to no work, so it names no work's entry")+        #expect(detail.citingPlaces.map(\.factCount) == [1, 1, 3],+                "the count is of this entry's citations; the generic-notes fact is not one")+        #expect(detail.citingCharacters.map(\.name) == ["Alex"],+                "Req 4.4: a separate section, so the character list is untouched")+        withExtendedLifetime(fixture) {}+    }++    @Test("An entry nothing cites carries no citing characters and no citing places")     func entryDetailIsEmptyWhereNothingCites() async throws {         let fixture = try await twoWorks()         let detail = try await fixture.repository.entryTeachingDetail(id: entry1)         #expect(detail.citingCharacters.isEmpty)+        #expect(detail.citingPlaces.isEmpty)         withExtendedLifetime(fixture) {}     } }
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift Modified +512 / -203
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swiftindex d24e1a6..e0c9822 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift@@ -18,12 +18,12 @@ 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)+) -> RecordFact {+    RecordFact(statement: statement, quote: quote, nameKey: key, source: source) } -@Suite("The character extraction candidate read (Q78)", .serialized)-struct CharacterExtractionCandidateReadTests {+@Suite("The extraction candidate read (Q78)", .serialized)+struct ExtractionCandidateReadTests {      private func fixture() async throws -> M5Fixture {         let fixture = try await M5Fixture()@@ -51,7 +51,7 @@ struct CharacterExtractionCandidateReadTests {     @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 candidates = try await fixture.repository.extractionCandidates(limit: 100)          let candidate = try #require(candidates.first { $0.workID == workA })         #expect(candidate.displayTitle == "Serial A")@@ -75,19 +75,19 @@ struct CharacterExtractionCandidateReadTests {     func coverageIsPerRevision() async throws {         let fixture = try await fixture()         let fingerprint = CharacterCoverageFingerprint.of("Hanna led the squad")-        let written = try await fixture.repository.advanceCharacterCoverage(+        let written = try await fixture.repository.advanceCoverage(             workID: workA,-            sources: [CharacterCompletedSource(ref: .entry(entry1), fingerprint: fingerprint)])+            sources: [CompletedSource(ref: .entry(entry1), fingerprint: fingerprint)])         #expect(written == 1)          var candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100)+            try await fixture.repository.extractionCandidates(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)+            try await fixture.repository.extractionCandidates(limit: 100)                 .first { $0.workID == workA })         #expect(candidate.uncoveredSources.count == 2,                 "editing the note *is* the invalidation of its old revision's coverage")@@ -100,14 +100,14 @@ struct CharacterExtractionCandidateReadTests {     @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(+        let written = try await fixture.repository.advanceCoverage(             workID: workA,-            sources: [CharacterCompletedSource(+            sources: [CompletedSource(                 ref: .entry(entry1), fingerprint: CharacterCoverageFingerprint.of("something else"))])         #expect(written == 0)          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100)+            try await fixture.repository.extractionCandidates(limit: 100)                 .first { $0.workID == workA })         #expect(candidate.uncoveredSources.count == 2)         withExtendedLifetime(fixture) {}@@ -121,7 +121,7 @@ struct CharacterExtractionCandidateReadTests {         try await fixture.repository.touchWorkModifiedAt(             workA, to: epoch.addingTimeInterval(10_000)) -        let candidates = try await fixture.repository.characterExtractionCandidates(limit: 100)+        let candidates = try await fixture.repository.extractionCandidates(limit: 100)         let candidate = try #require(candidates.first { $0.workID == workA })         #expect(candidate.recency == epoch.addingTimeInterval(10_000))         withExtendedLifetime(fixture) {}@@ -142,9 +142,9 @@ struct CharacterExtractionCandidateReadTests {             ])         try await fixture.repository.touchWorkModifiedAt(workB, to: epoch.addingTimeInterval(500)) -        let all = try await fixture.repository.characterExtractionCandidates(limit: 100)+        let all = try await fixture.repository.extractionCandidates(limit: 100)         #expect(all.map(\.workID) == [workB, workA])-        let capped = try await fixture.repository.characterExtractionCandidates(limit: 1)+        let capped = try await fixture.repository.extractionCandidates(limit: 1)         #expect(capped.map(\.workID) == [workB])         withExtendedLifetime(fixture) {}     }@@ -165,98 +165,143 @@ struct CharacterExtractionCandidateReadTests {                     genericNotes: "two"),             ]) -        let candidates = try await fixture.repository.characterExtractionCandidates(limit: 100)+        let candidates = try await fixture.repository.extractionCandidates(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 {+    @Test(+        "Accepted facts, match keys and active suppressions all come out of the one read",+        arguments: RecordKind.allCases)+    func filterInputIsComplete(kind: RecordKind) async throws {         let fixture = try await fixture()-        try await fixture.repository.seedM5Rows(-            characters: [-                M5SeedCharacter(+        try await fixture.repository.seedM5Records(+            kind,+            records: [+                M5SeedRecord(                     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(+                M5SeedRecordSuppression(+                    workID: workA, kind: .candidate, nameKey: "redevelopment law"),+                M5SeedRecordSuppression(                     workID: workA, kind: .fact, nameKey: "hanna",                     source: .genericNotes, evidence: "a quote"),             ])          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100)+            try await fixture.repository.extractionCandidates(limit: 100)                 .first { $0.workID == workA })-        #expect(candidate.acceptedFactIdentities-                == [CharacterFactIdentity(+        #expect(candidate.acceptedFacts(of: kind)+                == [RecordFactIdentity(                     nameKey: "hanna", source: .entry(entry1), quote: "she led")])-        #expect(candidate.suppressions.candidateKeys == ["redevelopment law"])-        #expect(candidate.suppressions.factIdentities-                == [CharacterFactIdentity(+        #expect(candidate.suppressions(of: kind).candidateKeys == ["redevelopment law"])+        #expect(candidate.suppressions(of: kind).factIdentities+                == [RecordFactIdentity(                     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)+        #expect(candidate.match(nameKey: "hanna", kind: kind)?.id == hanna)+        #expect(candidate.match(nameKey: "action girl", kind: kind)?.id == hanna)+        #expect(candidate.match(nameKey: "bruce", kind: kind) == nil)++        // Q13: the other kind's half of the read is untouched by any of it.+        let other: RecordKind = kind == .character ? .place : .character+        #expect(candidate.records(of: other).isEmpty)+        #expect(candidate.acceptedFacts(of: other).isEmpty)+        #expect(candidate.suppressions(of: other) == .empty)         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 {+    /// Q82: a clear must not be undone by an older suppression syncing in. The+    /// place rows converge by exactly the same rule, on the same tie-break.+    @Test(+        "Suppression convergence is by reader-action recency, cleared-wins on a tie",+        arguments: RecordKind.allCases)+    func suppressionConvergence(kind: RecordKind) 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: [+        try await fixture.repository.seedM5Records(kind, suppressions: [             // One key, two rows: an old suppression and a newer clear.-            M5SeedSuppression(+            M5SeedRecordSuppression(                 id: older, workID: workA, nameKey: "ghost", status: .active, actionAt: epoch),-            M5SeedSuppression(+            M5SeedRecordSuppression(                 id: newer, workID: workA, nameKey: "ghost", status: .cleared,                 actionAt: epoch.addingTimeInterval(60)),             // One key, two rows with the same instant: cleared wins.-            M5SeedSuppression(+            M5SeedRecordSuppression(                 id: tieA, workID: workA, nameKey: "wraith", status: .active, actionAt: epoch),-            M5SeedSuppression(+            M5SeedRecordSuppression(                 id: tieB, workID: workA, nameKey: "wraith", status: .cleared, actionAt: epoch),         ])          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100)+            try await fixture.repository.extractionCandidates(limit: 100)                 .first { $0.workID == workA })-        #expect(candidate.suppressions.candidateKeys.isEmpty,+        #expect(candidate.suppressions(of: kind).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 {+    /// Req 6.7 and Req 5.5: an orphan is inert whichever route it reached that+    /// state by. It must not be read as one of the work's records, and it must+    /// not take the read down.+    @Test(+        "An orphaned record does not join any work's match targets",+        arguments: RecordKind.allCases)+    func orphanIsNotAMatchTarget(kind: RecordKind) async throws {         let fixture = try await fixture()-        try await fixture.repository.seedM5Rows(characters: [-            M5SeedCharacter(id: hanna, name: "Hanna", workID: nil),+        try await fixture.repository.seedM5Records(kind, records: [+            M5SeedRecord(id: hanna, name: "Hanna", workID: nil),         ])          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100)+            try await fixture.repository.extractionCandidates(limit: 100)                 .first { $0.workID == workA })-        #expect(candidate.characters.isEmpty)+        #expect(candidate.records(of: kind).isEmpty)         withExtendedLifetime(fixture) {}     }-} -@Suite("Committing a character decision (Req 2.2, 2.7, 2.8)", .serialized)-struct CharacterDecisionCommitTests {+    /// Both halves come out of the one read, per work, for every work the sweep+    /// examines — the place half through a single fetch grouped in memory+    /// (`swiftdata-relationships.md` rule 2) rather than one fetch per work.+    @Test("One read carries both kinds for every examined work")+    func bothKindsForEveryExaminedWork() async throws {+        let fixture = try await fixture()+        try await fixture.repository.touchWorkGenericNotes(workB, to: "Serial B's cast")+        try await fixture.repository.seedM5Rows(+            characters: [+                M5SeedCharacter(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),+            ],+            places: [+                M5SeedPlace(id: bruce, name: "Harbour", nameKey: "harbour", workID: workA),+                M5SeedPlace(+                    id: UUID(uuidString: "0F000000-0000-4000-8000-000000000501")!,+                    name: "Lighthouse", nameKey: "lighthouse", workID: workB),+            ])++        let candidates = try await fixture.repository.extractionCandidates(limit: 100)+        let a = try #require(candidates.first { $0.workID == workA })+        let b = try #require(candidates.first { $0.workID == workB })+        #expect(a.records(of: .character).map(\.id) == [hanna])+        #expect(a.records(of: .place).map(\.id) == [bruce])+        #expect(b.records(of: .character).isEmpty)+        #expect(b.records(of: .place).map(\.currentNameKey) == ["lighthouse"],+                "the second work's places came out of the same read")+        withExtendedLifetime(fixture) {}+    }+}+@Suite("Committing an extraction decision (Req 2.2, 2.7, 2.8)", .serialized)+struct RecordDecisionCommitTests {      private func fixture(-        characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []+        _ kind: RecordKind,+        records: [M5SeedRecord] = [], suppressions: [M5SeedRecordSuppression] = []     ) async throws -> M5Fixture {         let fixture = try await M5Fixture()         try await fixture.repository.seedM5Rows(@@ -266,9 +311,9 @@ struct CharacterDecisionCommitTests {                 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)+                note: "Hanna led the squad", workID: workA)])+        try await fixture.repository.seedM5Records(+            kind, records: records, suppressions: suppressions)         return fixture     } @@ -276,61 +321,70 @@ struct CharacterDecisionCommitTests {         CharacterCoverageFingerprint.of("Hanna led the squad")     } -    private func acceptNewHanna() -> CharacterDecisionRequest {-        CharacterDecisionRequest(+    private func acceptNewHanna(_ kind: RecordKind) -> DecisionRequest {+        DecisionRequest(             workID: workA,+            kind: kind,             action: .accept,             displayedKeys: ["hanna"],             displayedTargetID: nil,             proposedName: "Hanna",             facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))],-            completedSources: [CharacterCompletedSource(+            completedSources: [CompletedSource(                 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),+    @Test(+        "Accepting a candidate creates the record, covers the source and clears its key",+        arguments: RecordKind.allCases)+    func acceptCandidate(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, suppressions: [+            M5SeedRecordSuppression(+                workID: workA, nameKey: "hanna", status: .active, actionAt: epoch),         ]) -        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        let outcome = try await fixture.repository.commitDecision(acceptNewHanna(kind))         guard case .committed(let id?) = outcome else {-            Issue.record("expected a committed character, got \(outcome)")+            Issue.record("expected a committed record, got \(outcome)")             return         } -        let rows = try await fixture.repository.m5CharacterRows(id: id)+        let rows = try await fixture.repository.m5RecordRows(kind, 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)+            try await fixture.repository.extractionCandidates(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")+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty,+                "Req 2.5: acceptance clears the key")+        #expect(candidate.records(of: kind).map(\.id) == [id],+                "the new record is the work's, under its own kind")         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(+    @Test(+        "Accepting a bundle appends to the existing record and installs unstruck aliases",+        arguments: RecordKind.allCases)+    func acceptBundle(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(                 id: hanna, name: "Hanna", nameKey: "hanna",                 facts: [fact("Wears red", "her red coat", .genericNotes)], workID: workA),         ]) -        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         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 outcome = try await fixture.repository.commitDecision(request)+        #expect(outcome == .committed(recordID: hanna)) -        let rows = try await fixture.repository.m5CharacterRows(id: hanna)+        let rows = try await fixture.repository.m5RecordRows(kind, id: hanna)         #expect(rows.first?.facts.count == 2)         #expect(rows.first?.aliases == ["Action Girl"])         withExtendedLifetime(fixture) {}@@ -338,16 +392,18 @@ struct CharacterDecisionCommitTests {      /// 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(+    @Test(+        "A fact matching an accepted identity is not appended twice",+        arguments: RecordKind.allCases)+    func acceptedFactsDedup(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(                 id: hanna, name: "Hanna", nameKey: "hanna",                 facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))],                 workID: workA),         ]) -        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         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.@@ -355,8 +411,8 @@ struct CharacterDecisionCommitTests {             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)+        _ = try await fixture.repository.commitDecision(request)+        let rows = try await fixture.repository.m5RecordRows(kind, id: hanna)         #expect(rows.first?.facts.count == 1)         withExtendedLifetime(fixture) {}     }@@ -364,68 +420,73 @@ struct CharacterDecisionCommitTests {     /// 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()+    @Test(+        "A cited revision that changed refuses the acceptance and writes nothing",+        arguments: RecordKind.allCases)+    func staleSourceRefusesAcceptance(kind: RecordKind) async throws {+        let fixture = try await fixture(kind)         try await fixture.repository.rewriteEntryNote(entry1, to: "somebody else led the squad") -        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        let outcome = try await fixture.repository.commitDecision(acceptNewHanna(kind))         #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")+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.records(of: kind).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),+    /// Q66: facts must never commit to a record the reader was not shown.+    @Test(+        "A candidate displayed as new that now resolves onto a record refuses as re-routed",+        arguments: RecordKind.allCases)+    func newCandidateOntoExistingRefuses(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),         ]) -        let outcome = try await fixture.repository.commitCharacterDecision(acceptNewHanna())+        let outcome = try await fixture.repository.commitDecision(acceptNewHanna(kind))         #expect(outcome == .refused(.reRouted(to: hanna)))-        let rows = try await fixture.repository.m5CharacterRows(id: hanna)+        let rows = try await fixture.repository.m5RecordRows(kind, 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),+    @Test(+        "A bundle whose target no longer matches refuses as re-routed",+        arguments: RecordKind.allCases)+    func bundleTargetMovedRefuses(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: bruce, name: "Bruce", nameKey: "bruce", workID: workA),         ]) -        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         request.displayedTargetID = bruce -        let outcome = try await fixture.repository.commitCharacterDecision(request)+        let outcome = try await fixture.repository.commitDecision(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(+    @Test("A torn record refuses acceptance, naming itself", arguments: RecordKind.allCases)+    func tornRecordRefusesAcceptance(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", note: "brave", workID: workA),+            M5SeedRecord(                 id: hanna, name: "Hanna", nameKey: "hanna", note: "reckless", workID: workA),         ]) -        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         request.displayedTargetID = hanna -        let outcome = try await fixture.repository.commitCharacterDecision(request)-        #expect(outcome == .refused(.torn(characterID: hanna)))+        let outcome = try await fixture.repository.commitDecision(request)+        #expect(outcome == .refused(.torn(recordID: hanna)))         withExtendedLifetime(fixture) {}     } -    @Test("A torn work refuses acceptance")-    func tornWorkRefusesAcceptance() async throws {+    @Test("A torn work refuses acceptance", arguments: RecordKind.allCases)+    func tornWorkRefusesAcceptance(kind: RecordKind) async throws {         let fixture = try await M5Fixture()         try await fixture.repository.seedM5Rows(             sites: [M5SeedSite(hostname: "c.example")],@@ -438,50 +499,53 @@ struct CharacterDecisionCommitTests {                     genericNotes: "two"),             ]) -        let outcome = try await fixture.repository.commitCharacterDecision(-            CharacterDecisionRequest(-                workID: workA, action: .accept, displayedKeys: ["hanna"],+        let outcome = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .accept, displayedKeys: ["hanna"],                 proposedName: "Hanna"))-        #expect(outcome == .refused(.torn(characterID: nil)))+        #expect(outcome == .refused(.torn(recordID: 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(+    @Test(+        "Skipping is refused by nothing: it still records under staleness and under a tear",+        arguments: RecordKind.allCases)+    func skipIsNeverRefused(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", note: "brave", workID: workA),+            M5SeedRecord(                 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"],+        let outcome = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .skip, displayedKeys: ["ghost"],                 proposedName: "Ghost",-                completedSources: [CharacterCompletedSource(+                completedSources: [CompletedSource(                     ref: .entry(entry1), fingerprint: entryFingerprint)]))-        #expect(outcome == .committed(characterID: nil))+        #expect(outcome == .committed(recordID: nil))          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100).first)-        #expect(candidate.suppressions.candidateKeys == ["ghost"])+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).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,+    @Test(+        "Skipping a candidate suppresses exactly the keys its row displayed",+        arguments: RecordKind.allCases)+    func skipSuppressesDisplayedKeysOnly(kind: RecordKind) async throws {+        let fixture = try await fixture(kind)++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .skip,                 displayedKeys: ["hanna"],                 proposedName: "Hanna/Action Girl",                 proposedAliases: [],@@ -490,14 +554,14 @@ struct CharacterDecisionCommitTests {                 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"],+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).candidateKeys == ["hanna"],                 "the struck alias half was not displayed, so it is not suppressed")-        #expect(candidate.suppressions.factIdentities.isEmpty,+        #expect(candidate.suppressions(of: kind).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 \+                facts out of a record later created under that name, where \                 the key suppression no longer applies (Q47)                 """)         withExtendedLifetime(fixture) {}@@ -505,110 +569,115 @@ struct CharacterDecisionCommitTests {      /// 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 {+    /// two devices attaching one proposal's facts to two records.+    @Test(+        "A proposal resolves by tier first and by lowest UUID within a tier",+        arguments: RecordKind.allCases)+    func matchingPrecedenceAndTieBreak(kind: RecordKind) 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),+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: byRetainedKey, name: "Alex", nameKey: "hanna", workID: workA),+            M5SeedRecord(id: byCurrentName, name: "Hanna", nameKey: "terawatt", workID: workA),+            M5SeedRecord(id: alsoByCurrentName, name: "Hanna", nameKey: "bruce", workID: workA),         ])          // Shown against the current-name match with the lowest UUID: committed.-        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         request.displayedTargetID = byCurrentName-        #expect(try await fixture.repository.commitCharacterDecision(request)-                == .committed(characterID: byCurrentName))+        #expect(try await fixture.repository.commitDecision(request)+                == .committed(recordID: 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)+        #expect(try await fixture.repository.commitDecision(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)+        let rows = try await fixture.repository.m5RecordRows(kind, 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(+    /// Req 2.5's second limb: 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",+        arguments: RecordKind.allCases)+    func acceptanceClearsFactSuppression(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, suppressions: [+            M5SeedRecordSuppression(                 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())+        let outcome = try await fixture.repository.commitDecision(acceptNewHanna(kind))         guard case .committed = outcome else {-            Issue.record("expected a committed character, got \(outcome)")+            Issue.record("expected a committed record, got \(outcome)")             return         }          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100).first)-        #expect(candidate.suppressions.factIdentities.isEmpty,+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).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+    /// writing one for a bundle would freeze an existing record 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),+    @Test(+        "Skipping a bundle suppresses the facts and never the record's name key",+        arguments: RecordKind.allCases)+    func skipBundleSuppressesFactsOnly(kind: RecordKind) async throws {+        let fixture = try await fixture(kind, records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),         ]) -        _ = try await fixture.repository.commitCharacterDecision(-            CharacterDecisionRequest(-                workID: workA, action: .skip,+        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, 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(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty)+        #expect(candidate.suppressions(of: kind).factIdentities+                == [RecordFactIdentity(                     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()+    @Test(+        "Unticking a fact inside an accepted candidate suppresses that fact's triple",+        arguments: RecordKind.allCases)+    func untickSuppressesTheFact(kind: RecordKind) async throws {+        let fixture = try await fixture(kind) -        var request = acceptNewHanna()+        var request = acceptNewHanna(kind)         request.untickedFacts = [-            CharacterFactIdentity(nameKey: "hanna", source: .genericNotes, quote: "the cast"),+            RecordFactIdentity(nameKey: "hanna", source: .genericNotes, quote: "the cast"),         ]-        _ = try await fixture.repository.commitCharacterDecision(request)+        _ = try await fixture.repository.commitDecision(request)          let candidate = try #require(-            try await fixture.repository.characterExtractionCandidates(limit: 100).first)-        #expect(candidate.suppressions.factIdentities-                == [CharacterFactIdentity(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).factIdentities+                == [RecordFactIdentity(                     nameKey: "hanna", source: .genericNotes, quote: "the cast")])         withExtendedLifetime(fixture) {}     }@@ -616,32 +685,261 @@ struct CharacterDecisionCommitTests {     /// 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()+    @Test(+        "A second decision on one key updates the row in place",+        arguments: RecordKind.allCases)+    func suppressionWritesInPlace(kind: RecordKind) async throws {+        let fixture = try await fixture(kind)+        let skip = DecisionRequest(+            workID: workA, kind: kind, action: .skip, displayedKeys: ["ghost"],+            proposedName: "Ghost")++        _ = try await fixture.repository.commitDecision(skip)+        _ = try await fixture.repository.commitDecision(skip)++        let rows = try await fixture.repository.m5RecordSuppressionRows(kind)         #expect(rows.count == 1)         #expect(rows.first?.status == .active)         withExtendedLifetime(fixture) {}     } +    /// Coverage is one record per source revision whatever decided it (Q9): a+    /// place decision covers the note as a character decision would, and the+    /// next sweep does not re-read it for the other kind.+    @Test(+        "Coverage advances once per source, whichever kind decided it",+        arguments: RecordKind.allCases)+    func coverageAdvancesWhicheverKindDecided(kind: RecordKind) async throws {+        let fixture = try await fixture(kind)++        _ = try await fixture.repository.commitDecision(acceptNewHanna(kind))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.uncoveredSources.map(\.ref) == [.genericNotes],+                "one coverage column per source, not one per kind (Q9)")++        // And `advanceCoverage` — the produced-none path — is the same call for+        // both kinds, so covering the remaining source needs no second column.+        let written = try await fixture.repository.advanceCoverage(+            workID: workA,+            sources: [CompletedSource(+                ref: .genericNotes,+                fingerprint: CharacterCoverageFingerprint.of("the cast is Hanna"))])+        #expect(written == 1)+        #expect(try await fixture.repository.extractionCandidates(limit: 100)+            .allSatisfy { $0.uncoveredSources.isEmpty },+            "nothing is left for a sweep of either kind to re-read")+        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(+        let fixture = try await fixture(.character)+        let outcome = try await fixture.repository.commitDecision(+            DecisionRequest(                 workID: workB, action: .accept, proposedName: "Hanna"))         #expect(outcome == .refused(.workGone))         withExtendedLifetime(fixture) {}     } } +// Q13: suppression, fact identity and dedup are per record kind, and the+// commit is the only place that can break it. Every arm here is about a write+// under one kind being invisible to the other — except the dual-kind skip,+// which is the deliberate exception Q23 states.++@Suite("Decisions are per record kind (Q13, Q23)", .serialized)+struct CrossKindDecisionTests {++    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")],+            entries: [M5SeedEntry(+                id: entry1, captureTitle: "Ch 1", hostname: "c.example", path: "1",+                note: "Hanna led the squad", workID: workA)])+        return fixture+    }++    private func other(_ kind: RecordKind) -> RecordKind {+        kind == .character ? .place : .character+    }++    /// A skipped place "Bay" must not suppress a character "Bay", and the+    /// reverse.+    @Test("A skip under one kind leaves the other kind's key untouched",+          arguments: RecordKind.allCases)+    func skipIsPerKind(kind: RecordKind) async throws {+        let fixture = try await fixture()++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .skip, displayedKeys: ["bay"],+                proposedName: "Bay"))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).candidateKeys == ["bay"])+        #expect(candidate.suppressions(of: other(kind)).candidateKeys.isEmpty,+                "two record kinds share one name-key space and no decisions (Q13)")+        withExtendedLifetime(fixture) {}+    }++    /// Q23: one row skipped once, under every kind the pass returned it under.+    @Test("A dual-kind skip with no target suppresses the key under both kinds")+    func dualKindSkipSuppressesBoth() async throws {+        let fixture = try await fixture()++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: .character,+                returnedKinds: [.character, .place],+                action: .skip, displayedKeys: ["bay"], proposedName: "Bay"))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: .character).candidateKeys == ["bay"])+        #expect(candidate.suppressions(of: .place).candidateKeys == ["bay"],+                "otherwise the next revision re-proposes the name under the other kind")+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.4: the other kind is suppressed only for a Req 1.5 union row. A+    /// single-kind row the reader reclassified (Req 2.2) carries a+    /// `returnedKinds` that no longer contains the kind it is decided under,+    /// and skipping it must still touch one kind — the displayed one.+    @Test("A reclassified single-kind skip suppresses only the displayed kind")+    func reclassifiedSkipSuppressesDisplayedKindOnly() async throws {+        let fixture = try await fixture()++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: .place,+                returnedKinds: [.character],+                action: .skip, displayedKeys: ["bay"], proposedName: "Bay"))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: .place).candidateKeys == ["bay"])+        #expect(candidate.suppressions(of: .character).candidateKeys.isEmpty,+                "the row was never a union row, so the reader skipped one kind only")+        withExtendedLifetime(fixture) {}+    }++    /// Q33: a bundle skip never suppresses a name key, so a dual-kind bundle+    /// skip suppresses no key under either kind.+    @Test("A dual-kind skip against an existing record still suppresses no name key")+    func dualKindBundleSkipSuppressesNoKey() async throws {+        let fixture = try await fixture()+        try await fixture.repository.seedM5Records(.character, records: [+            M5SeedRecord(id: hanna, name: "Bay", nameKey: "bay", workID: workA),+        ])++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: .character,+                returnedKinds: [.character, .place],+                action: .skip, displayedKeys: ["bay"], displayedTargetID: hanna,+                proposedName: "Bay",+                facts: [fact("Leads", "Hanna led the squad", .entry(entry1), key: "bay")]))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: .character).candidateKeys.isEmpty)+        #expect(candidate.suppressions(of: .place).candidateKeys.isEmpty)+        #expect(candidate.suppressions(of: .character).factIdentities.count == 1)+        #expect(candidate.suppressions(of: .place).factIdentities.isEmpty,+                "the facts belong to the record that was bundled, and it has one kind")+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.5 under a kind: accepting clears that kind's standing suppression+    /// and leaves the other kind's alone, however identical the key.+    @Test("Accepting under one kind clears only that kind's suppressions",+          arguments: RecordKind.allCases)+    func acceptClearsOneKindOnly(kind: RecordKind) async throws {+        let fixture = try await fixture()+        for seeded in RecordKind.allCases {+            try await fixture.repository.seedM5Records(seeded, suppressions: [+                M5SeedRecordSuppression(+                    workID: workA, nameKey: "hanna", status: .active, actionAt: epoch),+                M5SeedRecordSuppression(+                    workID: workA, kind: .fact, nameKey: "hanna",+                    source: .entry(entry1), evidence: "Hanna led the squad",+                    status: .active, actionAt: epoch),+            ])+        }++        _ = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .accept, displayedKeys: ["hanna"],+                proposedName: "Hanna",+                facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))]))++        let candidate = try #require(+            try await fixture.repository.extractionCandidates(limit: 100).first)+        #expect(candidate.suppressions(of: kind).candidateKeys.isEmpty)+        #expect(candidate.suppressions(of: kind).factIdentities.isEmpty)+        #expect(candidate.suppressions(of: other(kind)).candidateKeys == ["hanna"],+                "the other kind's reader decision stands")+        #expect(candidate.suppressions(of: other(kind)).factIdentities.count == 1)+        withExtendedLifetime(fixture) {}+    }++    /// Q66 under a kind: the re-route check runs over the records of+    /// `request.kind` alone, so a character named "Hanna" does not re-route a+    /// place candidate of the same name.+    @Test("Re-routing is evaluated under the request's kind",+          arguments: RecordKind.allCases)+    func reRoutingIsPerKind(kind: RecordKind) async throws {+        let fixture = try await fixture()+        try await fixture.repository.seedM5Records(other(kind), records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", workID: workA),+        ])++        let outcome = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: kind, action: .accept, displayedKeys: ["hanna"],+                proposedName: "Hanna",+                facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))]))+        guard case .committed(let id?) = outcome else {+            Issue.record("expected a committed record, got \(outcome)")+            return+        }+        #expect(id != hanna, "a record of the other kind is not this decision's target")+        #expect(try await fixture.repository.m5RecordRows(other(kind), id: hanna)+            .first?.facts.isEmpty == true,+            "and it received nothing")+        withExtendedLifetime(fixture) {}+    }++    /// Req 2.8 over the second table: acceptance onto a torn place is refused,+    /// and the tear is reported under the kind the request named.+    @Test("A torn record of the other kind does not refuse this kind's acceptance")+    func tornOtherKindDoesNotRefuse() async throws {+        let fixture = try await fixture()+        try await fixture.repository.seedM5Records(.place, records: [+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", note: "a", workID: workA),+            M5SeedRecord(id: hanna, name: "Hanna", nameKey: "hanna", note: "b", workID: workA),+        ])++        let outcome = try await fixture.repository.commitDecision(+            DecisionRequest(+                workID: workA, kind: .character, action: .accept, displayedKeys: ["hanna"],+                proposedName: "Hanna",+                facts: [fact("Leads the squad", "Hanna led the squad", .entry(entry1))]))+        guard case .committed = outcome else {+            Issue.record("expected a committed character, got \(outcome)")+            return+        }+        withExtendedLifetime(fixture) {}+    }+}+ // MARK: - Seeding helpers these suites need  extension LibraryRepository {@@ -657,6 +955,17 @@ extension LibraryRepository {         }     } +    /// Gives a work generic notes, so a suite can make a second work a+    /// candidate without re-seeding it.+    func touchWorkGenericNotes(_ id: UUID, to notes: String) async throws {+        try await withLockedContext(mode: .exclusive, operation: "writing generic notes") { context in+            let rows = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == id }))+            for row in rows { row.genericNotes = notes }+            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
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift Modified +33 / -33
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swiftindex a267278..45b5a31 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterFactsTests.swift@@ -11,11 +11,11 @@ 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")+        #expect(RecordNameKey.normalize("  Terawatt  ") == "terawatt")+        #expect(RecordNameKey.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"))+        #expect(RecordNameKey.normalize("Rene\u{0301}e") == RecordNameKey.normalize("Renée"))     }      /// The Turkish-I case, which is the whole reason the fold is locale-free: a@@ -23,9 +23,9 @@ struct CharacterNameKeyTests {     /// 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")+        #expect(RecordNameKey.normalize("Ianthe") == "ianthe")+        #expect(RecordNameKey.normalize("IANTHE") == "ianthe")+        #expect(RecordNameKey.normalize("Ianthe") != RecordNameKey.normalize("Ianthe")             .replacingOccurrences(of: "i", with: "\u{0131}"),                 "the dotless ı is a different name, not a fold of I")     }@@ -33,8 +33,8 @@ struct CharacterNameKeyTests {     @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")+            let once = RecordNameKey.normalize(name)+            #expect(RecordNameKey.normalize(once) == once, "re-keying \(name) moved the key")         }     } @@ -42,28 +42,28 @@ struct CharacterNameKeyTests {     /// 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",+        #expect(RecordNameKey.normalize("The Crowned One") == "crowned one")+        #expect(RecordNameKey.normalize("crowned one") == "crowned one")+        #expect(RecordNameKey.normalize("Theodore") == "theodore",                 "the article needs its trailing space; a name starting \"the\" is not an article")-        #expect(CharacterNameKey.normalize("A Queen") == "a queen",+        #expect(RecordNameKey.normalize("A Queen") == "a queen",                 "only \"the\" is stripped; extending the list is speculation")-        #expect(CharacterNameKey.normalize("The   Crowned One") == "crowned one",+        #expect(RecordNameKey.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")+        #expect(RecordNameKey.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)+        #expect(RecordNameKey.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",+        #expect(RecordNameKey.normalize("the ") == "the")+        #expect(RecordNameKey.normalize("The The") == "the",                 "the trailing word is a name, not a second article to strip")     } }@@ -76,8 +76,8 @@ struct CharacterFactCodecTests {      private func fact(         _ statement: String, _ quote: String, _ source: SourceRef, key: String = "hanna"-    ) -> CharacterFact {-        CharacterFact(statement: statement, quote: quote, nameKey: key, source: source)+    ) -> RecordFact {+        RecordFact(statement: statement, quote: quote, nameKey: key, source: source)     }      /// The property the whole tear story rests on: two devices holding the same@@ -89,8 +89,8 @@ struct CharacterFactCodecTests {             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())+        let forward = RecordFactCodec.encode(facts)+        let backward = RecordFactCodec.encode(facts.reversed())         #expect(forward != nil)         #expect(forward == backward)     }@@ -104,7 +104,7 @@ struct CharacterFactCodecTests {             fact("First", "a", .entry(Self.entryA)),             fact("Zeroth", "z", .genericNotes),         ]-        #expect(CharacterFactCodec.canonicalOrder(facts).map(\.statement)+        #expect(RecordFactCodec.canonicalOrder(facts).map(\.statement)                 == ["Zeroth", "First", "Second"])     } @@ -117,8 +117,8 @@ struct CharacterFactCodecTests {         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)+        #expect(RecordFactCodec.encode([one, other]) == RecordFactCodec.encode([other, one]))+        #expect(RecordFactCodec.canonicalOrder([one, other]).map(\.statement)                 == ["Leads the squad", "She led it"])     } @@ -128,8 +128,8 @@ struct CharacterFactCodecTests {             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))+        let decoded = RecordFactCodec.decode(RecordFactCodec.encode(facts))+        #expect(decoded == RecordFactCodec.canonicalOrder(facts))         #expect(decoded.map(\.nameKey) == ["hanna", "hanna"])         #expect(decoded.map(\.source) == [.genericNotes, .entry(Self.entryA)])     }@@ -138,16 +138,16 @@ struct CharacterFactCodecTests {     /// 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)+        #expect(RecordFactCodec.encode([]) == nil)+        #expect(RecordFactCodec.decode(nil).isEmpty)+        #expect(RecordFactCodec.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)+        #expect(RecordFactCodec.decode(Data("not json".utf8)).isEmpty)     }      /// The comparison seam: authored-content equality re-encodes rather than@@ -163,7 +163,7 @@ struct CharacterFactCodecTests {         encoder.outputFormatting = [.sortedKeys]         let unordered = try? encoder.encode(facts)         #expect(unordered != nil)-        #expect(CharacterFactCodec.canonicalBytes(unordered) == CharacterFactCodec.encode(facts))+        #expect(RecordFactCodec.canonicalBytes(unordered) == RecordFactCodec.encode(facts))     } } @@ -193,7 +193,7 @@ struct SourceRefTests {     /// 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(+        let base = RecordFact(             statement: "A", quote: "q", nameKey: "hanna", source: .entry(Self.entry))         var edited = base         edited.statement = "B"@@ -209,7 +209,7 @@ struct SourceRefTests {     /// 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(+        let base = RecordFact(             statement: "Leads", quote: "she led", nameKey: "hanna", source: .genericNotes)         #expect(base.rekeyed(to: "actiongirl").quote == "she led")         #expect(base.rekeyed(to: "actiongirl").statement == "Leads")
Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift Modified +28 / -28
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swiftindex 578b204..427eb4a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift@@ -146,34 +146,34 @@ struct CharacterRankingTests {      // MARK: - Fixtures for the scorer -    private func fact(_ source: SourceRef, _ quote: String) -> CharacterFact {-        CharacterFact(+    private func fact(_ source: SourceRef, _ quote: String) -> RecordFact {+        RecordFact(             statement: "what \(quote) says", quote: quote,-            nameKey: CharacterNameKey.normalize("Ada"), source: source)+            nameKey: RecordNameKey.normalize("Ada"), source: source)     }      /// `count` **distinct** facts citing one source. Distinct because a fact's     /// identity is `(name key, source, quote)`, so a repeated quote would be     /// one fact wearing two statements.-    private func facts(_ count: Int, citing source: SourceRef, tag: String = "") -> [CharacterFact] {+    private func facts(_ count: Int, citing source: SourceRef, tag: String = "") -> [RecordFact] {         (0..<count).map { fact(source, "quote \($0)\(tag) of \(source.orderToken)") }     } -    private func group(_ name: String, _ facts: [CharacterFact], id: UUID = UUID()) -> CharacterGroup {+    private func group(_ name: String, _ facts: [RecordFact], id: UUID = UUID()) -> CharacterGroup {         let record = CharacterRecord(-            id: id, name: name, nameKey: CharacterNameKey.normalize(name), facts: facts,+            id: id, name: name, nameKey: RecordNameKey.normalize(name), facts: facts,             timestamp: Self.epoch)         // Nil only for an empty row list, which this never passes.-        return LibraryRepository.characterGroup(id: id, rows: [record])!+        return LibraryRepository.recordGroup(id: id, rows: [record])!     }      private func rankedNames(_ groups: [CharacterGroup], index: StoryPositionIndex) -> [String] {         let keyed = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0) })-        return CharacterRanking.rank(keyed, index: index).map(\.group.presentedContent.name)+        return RecordRanking.rank(keyed, index: index).map(\.group.presentedContent.name)     } -    private func score(_ facts: [CharacterFact], _ index: StoryPositionIndex) -> Double {-        CharacterRanking.score(facts: facts, index: index)+    private func score(_ facts: [RecordFact], _ index: StoryPositionIndex) -> Double {+        RecordRanking.score(facts: facts, index: index)     }      /// A chapter-per-entry index: the returned `ids[d]` is the entry whose@@ -198,22 +198,22 @@ struct CharacterRankingTests {     /// reference, where the quotient is small enough for that error to vanish.     @Test("The decay table is 2^(-d/H), and H is 10 (1.2, Decision 3)")     func decayTableMatchesThePowerOfTwo() {-        #expect(CharacterRanking.halfLife == 10)-        #expect(CharacterRanking.weight(distance: 0) == 1.0)+        #expect(RecordRanking.halfLife == 10)+        #expect(RecordRanking.weight(distance: 0) == 1.0)         // Exactly half a half-life away, by construction rather than by libm.-        #expect(CharacterRanking.weight(distance: 10) == 0.5)-        #expect(CharacterRanking.weight(distance: 20) == 0.25)+        #expect(RecordRanking.weight(distance: 10) == 0.5)+        #expect(RecordRanking.weight(distance: 20) == 0.25) -        for k in 0..<CharacterRanking.halfLife {+        for k in 0..<RecordRanking.halfLife {             let reference = pow(2.0, -Double(k) / 10.0)             #expect(-                abs(CharacterRanking.weight(distance: k) - reference) <= reference.ulp,+                abs(RecordRanking.weight(distance: k) - reference) <= reference.ulp,                 "table entry \(k) is more than one ulp from 2^(-\(k)/10)")         }         for distance in 0..<100 {             let reference = pow(2.0, -Double(distance) / 10.0)             #expect(-                abs(CharacterRanking.weight(distance: distance) - reference) <= 4 * reference.ulp,+                abs(RecordRanking.weight(distance: distance) - reference) <= 4 * reference.ulp,                 "weight(\(distance)) is more than four ulps from 2^(-\(distance)/10)")         }     }@@ -221,12 +221,12 @@ struct CharacterRankingTests {     /// Req 1.2's conditions on `f`, pinned on the chosen one (Q28).     @Test("f(n) = log2(n + 1) is 1 at one fact, rising, with f(n)/n falling (1.2)")     func perBucketCurve() {-        #expect(CharacterRanking.perBucket(1) == 1.0)+        #expect(RecordRanking.perBucket(1) == 1.0)         for n in 1..<64 {-            #expect(CharacterRanking.perBucket(n) < CharacterRanking.perBucket(n + 1))+            #expect(RecordRanking.perBucket(n) < RecordRanking.perBucket(n + 1))             #expect(-                CharacterRanking.perBucket(n + 1) / Double(n + 1)-                    < CharacterRanking.perBucket(n) / Double(n))+                RecordRanking.perBucket(n + 1) / Double(n + 1)+                    < RecordRanking.perBucket(n) / Double(n))         }     } @@ -275,13 +275,13 @@ struct CharacterRankingTests {         #expect(index.earliestDistance == 2)          let dangling = score([fact(.entry(UUID()), "gone")], index)-        #expect(dangling == CharacterRanking.weight(distance: 2))+        #expect(dangling == RecordRanking.weight(distance: 2))          // Beside a live fact at the same distance it stays a second bucket.         let separate = score(             [fact(.entry(ids[2]), "live"), fact(.entry(UUID()), "gone")], index)         let together = score(facts(2, citing: .entry(ids[2])), index)-        #expect(separate == 2 * CharacterRanking.weight(distance: 2))+        #expect(separate == 2 * RecordRanking.weight(distance: 2))         #expect(separate > together)     } @@ -354,7 +354,7 @@ struct CharacterRankingTests {         for positions in [11, 400] {             let (index, ids) = chapterIndex(positions: positions)             let recent = group("zoe", facts(3, citing: .entry(ids[0])))-            let old = group("ada", facts(6, citing: .entry(ids[CharacterRanking.halfLife])))+            let old = group("ada", facts(6, citing: .entry(ids[RecordRanking.halfLife])))              #expect(rankedNames([old, recent], index: index) == ["zoe", "ada"])         }@@ -391,7 +391,7 @@ struct CharacterRankingTests {                 input(id, title: "an unnumbered note", capturedAfter: TimeInterval(offset))             })         let oldest = try #require(ids2.first)-        #expect(CharacterRanking.weight(distance: try #require(deep.distance(of: oldest))) == 0)+        #expect(RecordRanking.weight(distance: try #require(deep.distance(of: oldest))) == 0)         let zeroScored = group("zoe", facts(1, citing: .entry(oldest)))         #expect(rankedNames([zeroScored, empty], index: deep) == ["zoe", "ada"])     }@@ -450,7 +450,7 @@ struct CharacterRankingTests {              var groups: [CharacterGroup] = []             for character in 0..<Int.random(in: 1...5, using: &rng) {-                var made: [CharacterFact] = []+                var made: [RecordFact] = []                 for ordinal in 0..<Int.random(in: 0...6, using: &rng) {                     let source: SourceRef                     switch Int.random(in: 0...3, using: &rng) {@@ -502,7 +502,7 @@ struct CharacterRankingTests {          for _ in 0..<500 {             var occupied: Set<Int> = []-            var made: [CharacterFact] = []+            var made: [RecordFact] = []             for _ in 0..<Int.random(in: 1...4, using: &rng) {                 let distance = Int.random(in: 0..<ids.count, using: &rng)                 guard occupied.insert(distance).inserted else { continue }@@ -538,7 +538,7 @@ struct CharacterRankingTests {         let (index, ids) = chapterIndex(positions: 2)         // The existing normalisation drops a leading article, so these two         // display differently and key identically.-        #expect(CharacterNameKey.normalize("The Bear") == CharacterNameKey.normalize("bear"))+        #expect(RecordNameKey.normalize("The Bear") == RecordNameKey.normalize("bear"))          let low = UUID(uuidString: "00000000-0000-4000-8000-000000000001")!         let high = UUID(uuidString: "FFFFFFFF-0000-4000-8000-000000000002")!
Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swiftindex 54b0d97..786d87c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CitationBlobRefreshTests.swift@@ -247,12 +247,12 @@ private final class BlobStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismCitationBlob-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift Modified +15 / -15
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swiftindex 90db4f5..e7d7e10 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ConvergedRuleGroupValidationTests.swift@@ -249,17 +249,17 @@ struct ConvergedRuleGroupValidationTests {         // The premise: the store says this library is fine.         #expect(await repository.diagnostics.quarantineMap().isEmpty) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.titlePatterns.count == 1)         #expect(payload.titlePatterns.first?.id == shared)         #expect(payload.titlePatterns.first?.siteHostname == payload.sites.first?.hostname)         // The reference validator is what refuses a payload holding one rule         // UUID twice, so a decode is the assertion that matters here.-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))-        let decoded = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        let decoded = try BackupV12Codec.decode(encoded)         #expect(decoded.payload.titlePatterns.count == 1)     } @@ -289,15 +289,15 @@ struct ConvergedRuleGroupValidationTests {         // The premise, again: the store says this library is fine.         #expect(await repository.diagnostics.quarantineMap().isEmpty) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.titlePatterns.count == 1)         #expect(payload.titlePatterns.first?.isActive == true)         #expect(payload.sites.first?.mode == .taught)-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     }      /// The URL-rule counterpart, which is the quieter failure: the archive's@@ -316,7 +316,7 @@ struct ConvergedRuleGroupValidationTests {          #expect(await repository.diagnostics.quarantineMap().isEmpty) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.urlRules.count == 1)         #expect(payload.urlRules.first?.isCurrent == true)@@ -337,15 +337,15 @@ struct ConvergedRuleGroupValidationTests {          #expect(await repository.diagnostics.quarantineMap().isEmpty) -        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()          #expect(payload.urlRules.count == 1)         #expect(payload.urlRules.first?.id == shared)         #expect(payload.urlRules.first?.isCurrent == true)-        let encoded = try BackupV11Codec.encode(+        let encoded = try BackupV12Codec.encode(             payload: payload,-            metadata: BackupV11Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))-        _ = try BackupV11Codec.decode(encoded)+            metadata: BackupV12Metadata(appBuild: "1", exportedAt: RuleGroupStore.epoch))+        _ = try BackupV12Codec.decode(encoded)     } } @@ -367,12 +367,12 @@ private final class RuleGroupStore {         let directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismConvergedRules-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         let container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         self.init(context: ModelContext(container))         retained = container
Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swiftindex 745f0aa..7d716f6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorConvergenceTests.swift@@ -41,9 +41,9 @@ struct CreatorConvergenceTests {                 .appending(path: "CreatorConvergence-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(                 at: directory, withIntermediateDirectories: true)-            let schema = Schema(versionedSchema: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV12MigrationPlan.self,+                for: schema, migrationPlan: AsterismV13MigrationPlan.self,                 configurations: [                     ModelConfiguration(                         "AsterismV3", schema: schema,
Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swiftindex 66e0957..5b5dabf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CreatorRoleSeedingTests.swift@@ -90,7 +90,7 @@ struct CreatorRoleSeedingTests {         #expect(directory.options.map(\.name) == Self.expectedNames)     } -    /// A library converted from marker `"11"` holds no role rows at all, and its+    /// A library converted from marker `"12"` holds no role rows at all, and its     /// first open under this build is where the defaults arrive.     @Test("A library carrying no role rows is seeded on its next open")     func aLibraryWithNoRoleRowsIsSeededOnItsNextOpen() async throws {
Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swiftindex 88cea49..c4b6b8a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CreditReconcilerTests.swift@@ -35,9 +35,9 @@ struct CreditReconcilerTests {                 .appending(path: "CreditReconciler-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(                 at: directory, withIntermediateDirectories: true)-            let schema = Schema(versionedSchema: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV12MigrationPlan.self,+                for: schema, migrationPlan: AsterismV13MigrationPlan.self,                 configurations: [                     ModelConfiguration(                         "AsterismV3", schema: schema,
Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swiftindex d0b3ae4..9957890 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/CrossSiteDuplicateScanTests.swift@@ -244,12 +244,12 @@ private final class ScanStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismCrossSiteScan-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(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 573e003..43a86fc 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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.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 052078f..941425f 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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swiftindex 0b23965..1416862 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/EnumTolerancePolicyTests.swift@@ -207,9 +207,9 @@ struct EnumTolerancePolicyTests {         #expect(snapshot.works.contains { $0.id == workID })          do {-            _ = try await repository.backupV11Snapshot()+            _ = try await repository.backupV12Snapshot()             Issue.record("the export archived an unrepresentable value")-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             guard case .unrepresentableValue(let record, let field, let value) = error else {                 Issue.record("expected .unrepresentableValue, got \(error)")                 return@@ -246,9 +246,9 @@ struct EnumTolerancePolicyTests {         let repository = try await library.openForApp()          do {-            _ = try await repository.backupV11Snapshot()+            _ = try await repository.backupV12Snapshot()             Issue.record("the export archived an unreadable citation blob")-        } catch let error as BackupV11ExportError {+        } catch let error as BackupV12ExportError {             guard case .unrepresentableValue(_, let refused, _) = error else {                 Issue.record("expected .unrepresentableValue, got \(error)")                 return
Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swiftindex 1e0492f..f83058e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FanOutWriteTests.swift@@ -641,7 +641,7 @@ final class WriteFixture {     /// identity key — the upsert shape (Decision 8), so the plan updates rather     /// than inserts.     func importPlan(entryID: UUID, note: String, modifiedAt: Date) throws -> BackupImportPlan {-        let entry = BackupV11Entry(+        let entry = BackupV12Entry(             id: entryID, captureTitle: "Chapter", captureTitleSource: .host,             rawURL: identityKey, canonicalURL: nil, hostname: "dup.example",             entryIdentityKey: identityKey,@@ -650,7 +650,7 @@ final class WriteFixture {             note: note, rating: nil, firstCapturedAt: Self.epoch, lastSharedAt: Self.epoch,             modifiedAt: modifiedAt, workID: nil, intentionallyUnattached: false,             citations: EntryCitations())-        let site = BackupV11Site(+        let site = BackupV12Site(             hostname: "dup.example", displayName: "Dup", mode: .untaught, junkSuffixRule: nil)         let payload = BackupImportPayload(             entries: [entry], works: [], sites: [site], titlePatterns: [], urlRules: [])
Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swiftindex 7c645bd..20948ae 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FixtureArchiveGeneratorTests.swift@@ -29,8 +29,8 @@ import Testing /// extension is greyed out and cannot be selected on the device. The exporter's /// own filenames are `Asterism-backup-v4-<timestamp>.json` for the same reason. ///-/// The archive is produced through the real `BackupV11Exporter` — the same-/// `backupV11Snapshot()` → `BackupV11Codec.encode` → decode-validate → write path+/// The archive is produced through the real `BackupV12Exporter` — the same+/// `backupV12Snapshot()` → `BackupV12Codec.encode` → decode-validate → write path /// the app's Settings export uses — so what lands on disk is byte-for-byte the /// kind of file the app produces, checksum and all. The generator then re-reads /// the written file through `BackupImporter.plan(from:)`, which is the same@@ -96,9 +96,9 @@ struct FixtureArchiveGeneratorTests {         // it just produced. It picks its own filename in the staging directory;         // the archive is moved to `destination` afterwards.         let staging = root.appending(path: "staging", directoryHint: .isDirectory)-        let exporter = BackupV11Exporter(repository: repository, stagingDirectory: staging)+        let exporter = BackupV12Exporter(repository: repository, stagingDirectory: staging)         let result = try await exporter.export(-            metadata: BackupV11Metadata(appBuild: "fixture-5k", exportedAt: exportedAt))+            metadata: BackupV12Metadata(appBuild: "fixture-5k", exportedAt: exportedAt))         withExtendedLifetime(container) {}          try FileManager.default.createDirectory(
Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.json Renamed +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-11-12-golden.json b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.jsonsimilarity index 61%rename from Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-11-12-golden.jsonrename to Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.jsonindex 42ea6b8..7dc8014 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-11-12-golden.json+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/Fixtures/backup-12-13-golden.json@@ -1 +1 @@-{"appBuild":"golden","backupFormatVersion":11,"capabilityGate":"multi-site","checksum":"def8f374481427862e817aa4aa90f691f4f5a614fb003a2350fa4b28ba1860c2","databaseSchemaVersion":12,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"creatorRoles":[{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"author","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":0,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"artist","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":1,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"translator","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":2,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"E0000004-0000-4000-8000-000000000004","modifiedAt":"1970-01-12T13:46:40.000Z","name":"letterer","nameModifiedAt":"1970-01-12T13:46:40.000Z","position":3,"positionModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"E0000005-0000-4000-8000-000000000005","modifiedAt":"1970-01-12T13:46:40.000Z","name":"editor","nameModifiedAt":"1970-01-12T13:46:40.000Z","position":4,"positionModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"removed"}],"creators":[{"createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Mori Ayane","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"Also draws.","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Studio Lantern","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"canonicalID":"C8EA1080-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000003","modifiedAt":"1970-01-12T13:46:40.000Z","name":"mori ayane","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"merged"}],"credits":[{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000001","id":"C8ED1700-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000001-0000-4000-8000-000000000001","E0000005-0000-4000-8000-000000000005"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000002","id":"C8ED1700-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000002-0000-4000-8000-000000000002","E000000F-0000-4000-8000-00000000000F"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000001","id":"C8ED1700-0000-4000-8000-000000000003","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000001-0000-4000-8000-000000000001"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"links":[{"createdAt":"1970-01-12T13:46:40.000Z","higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"11115E51-0000-4000-8000-000000000001","linkType":"adaptation","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z"},{"createdAt":"1970-01-12T13:46:40.000Z","higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9","id":"11115E51-0000-4000-8000-000000000002","linkType":"spin-off","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"series":[{"createdAt":"1970-01-12T13:46:40.000Z","id":"5E81E5A0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Ashfall Cycle","notes":"Read 2.5 after 2."}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"finished","seriesID":"5E81E5A0-0000-4000-8000-000000000001","seriesPosition":1,"titleProvenance":"manual","typeName":"novel","verdict":"","workStatus":"finished","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","seriesID":"5E81E5A0-0000-4000-8000-000000000009","seriesPosition":4,"titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"abandoned","seriesID":"5E81E5A0-0000-4000-8000-000000000001","seriesPosition":2.5,"titleProvenance":"parsed","typeName":"novel","verdict":"Stalled three years in; I gave up waiting.","workStatus":"hiatus","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}\ No newline at end of file+{"appBuild":"golden","backupFormatVersion":12,"capabilityGate":"multi-site","checksum":"84e304535f688978a252307687ee3d52638d3200136fbd507dda6acf2157f4d0","databaseSchemaVersion":13,"entryCount":4,"exportedAt":"1970-01-12T13:46:40.000Z","payload":{"characters":[{"aliases":["Klar"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"grover","quote":"promised to guide them home","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Promised to guide them home."}],"id":"C4A2ACE0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Grover","nameKey":"grover","note":"The guide.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"C4A2ACE0-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Stranger","nameKey":"the stranger","note":""}],"creatorRoles":[{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"author","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":0,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"artist","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":1,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"E0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"translator","nameModifiedAt":"1970-01-01T00:00:00.000Z","position":2,"positionModifiedAt":"1970-01-01T00:00:00.000Z","stateModifiedAt":"1970-01-01T00:00:00.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"E0000004-0000-4000-8000-000000000004","modifiedAt":"1970-01-12T13:46:40.000Z","name":"letterer","nameModifiedAt":"1970-01-12T13:46:40.000Z","position":3,"positionModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"E0000005-0000-4000-8000-000000000005","modifiedAt":"1970-01-12T13:46:40.000Z","name":"editor","nameModifiedAt":"1970-01-12T13:46:40.000Z","position":4,"positionModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"removed"}],"creators":[{"createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Mori Ayane","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"Also draws.","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Studio Lantern","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"active"},{"canonicalID":"C8EA1080-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"C8EA1080-0000-4000-8000-000000000003","modifiedAt":"1970-01-12T13:46:40.000Z","name":"mori ayane","nameModifiedAt":"1970-01-12T13:46:40.000Z","notes":"","notesModifiedAt":"1970-01-12T13:46:40.000Z","stateModifiedAt":"1970-01-12T13:46:40.000Z","stateRaw":"merged"}],"credits":[{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000001","id":"C8ED1700-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000001-0000-4000-8000-000000000001","E0000005-0000-4000-8000-000000000005"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000002","id":"C8ED1700-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000002-0000-4000-8000-000000000002","E000000F-0000-4000-8000-00000000000F"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","creatorID":"C8EA1080-0000-4000-8000-000000000001","id":"C8ED1700-0000-4000-8000-000000000003","modifiedAt":"1970-01-12T13:46:40.000Z","roleIDs":["E0000001-0000-4000-8000-000000000001"],"workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"}],"distinctPairs":[{"higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"88888888-0000-4000-8000-000000000001","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","recordedAt":"1970-01-12T13:46:40.000Z"}],"entries":[{"captureTitle":"TtH • Story • Actual Title","captureTitleSource":"host","chapterSequence":"94","characterExtractionFingerprint":"448c04a700521270a7f5215cd2cfbbe77818591b29899fa94ca100201738f368","citations":{"chapterSequence":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"},"chapterTitle":{"kind":"none"},"identity":{"composed":{"nameTitle":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"},"url":{"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD"}}},"workAssignment":{"pattern":{"_0":{"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC"}}}},"conservativeIdentityKey":"https://golden.example/read?chapter=94&x=1","entryIdentityKey":"v3|h14:golden.example|n12:Actual Title|s2:94","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"22222222-2222-2222-2222-222222222222","identityBasis":"urlRule","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"Grover promised to guide them home.","rating":"up","rawURL":"https://golden.example/read?chapter=94&x=1","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"captureTitle":"Plain Work","captureTitleSource":"manual","chapterTitle":"A Plain Chapter","citations":{"chapterTitle":{"kind":"manual"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://plain.example/read/7","entryIdentityKey":"https://plain.example/read/7","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"22222222-2222-2222-2222-222222222223","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://plain.example/read/7","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"canonicalURL":"https://articles.example/posts/hello","captureTitle":"An Article - Articles Example","captureTitleSource":"host","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://articles.example/posts/hello?utm_source=share","entryIdentityKey":"https://articles.example/posts/hello?utm_source=share","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"22222222-2222-2222-2222-222222222224","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://articles.example/posts/hello?utm_source=share","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"captureTitle":"Twice Over","captureTitleSource":"manual","citations":{"chapterTitle":{"kind":"none"},"identity":{"rawURL":{}},"workAssignment":{"manual":{}}},"conservativeIdentityKey":"https://dupe.example/read/1","entryIdentityKey":"https://dupe.example/read/1","firstCapturedAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"D0000000-0000-4000-8000-000000000002","identityBasis":"conservative","intentionallyUnattached":false,"lastSharedAt":"1970-01-12T13:46:40.000Z","modifiedAt":"1970-01-12T13:46:40.000Z","note":"","rawURL":"https://dupe.example/read/1","workID":"D0000000-0000-4000-8000-000000000001"}],"links":[{"createdAt":"1970-01-12T13:46:40.000Z","higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","id":"11115E51-0000-4000-8000-000000000001","linkType":"adaptation","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z"},{"createdAt":"1970-01-12T13:46:40.000Z","higherWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9","id":"11115E51-0000-4000-8000-000000000002","linkType":"spin-off","lowerWorkID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z"}],"memberships":[{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"golden.example","id":"77777777-0000-4000-8000-000000000001","urlIdentity":"golden.example/story/actual-title","urlIdentityRuleID":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","urlIdentityState":"rule","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://golden.example/story/actual-title"},{"createdAt":"1970-01-12T13:46:41.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000002","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","workURLString":"https://plain.example/works/actual-title"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000003","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"articles.example","id":"77777777-0000-4000-8000-000000000004","urlIdentityState":"none","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"plain.example","id":"77777777-0000-4000-8000-000000000005","urlIdentity":"plain.example/absent","urlIdentityState":"legacyUnverified","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"},{"createdAt":"1970-01-12T13:46:40.000Z","hostname":"dupe.example","id":"77777777-0000-4000-8000-000000000006","urlIdentityState":"none","workID":"D0000000-0000-4000-8000-000000000001"}],"placeSuppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000011","kindRaw":"candidate","nameKey":"low road","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"above the pass","id":"5099E5ED-0000-4000-8000-000000000012","kindRaw":"fact","nameKey":"high keep","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"places":[{"aliases":["The Keep"],"createdAt":"1970-01-12T13:46:40.000Z","facts":[{"nameKey":"high keep","quote":"above the pass","source":{"entryID":"22222222-2222-2222-2222-222222222222","kind":"entry"},"statement":"Sits above the pass."}],"id":"91ACE000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The High Keep","nameKey":"high keep","note":"The fortress above the pass.","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"aliases":[],"createdAt":"1970-01-12T13:46:40.000Z","facts":[],"id":"91ACE000-0000-4000-8000-000000000002","modifiedAt":"1970-01-12T13:46:40.000Z","name":"The Drowned Road","nameKey":"drowned road","note":"","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE9"}],"series":[{"createdAt":"1970-01-12T13:46:40.000Z","id":"5E81E5A0-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","name":"Ashfall Cycle","notes":"Read 2.5 after 2."}],"sites":[{"displayName":"Articles","hostname":"articles.example","mode":"articles"},{"displayName":"Dupe","hostname":"dupe.example","mode":"untaught"},{"displayName":"Golden","hostname":"golden.example","junkSuffixRule":{"anchors":[{"offset":0,"origin":"end"}],"version":1},"mode":"taught"},{"displayName":"Plain","hostname":"plain.example","mode":"untaught"}],"suppressions":[{"actionAt":"1970-01-12T13:46:40.000Z","id":"5099E5ED-0000-4000-8000-000000000001","kindRaw":"candidate","nameKey":"the crowned one","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"},{"actionAt":"1970-01-12T13:46:40.000Z","evidence":"promised to guide them home","id":"5099E5ED-0000-4000-8000-000000000002","kindRaw":"fact","nameKey":"grover","sourceEntryID":"22222222-2222-2222-2222-222222222222","sourceKindRaw":"entry","statusRaw":"active","workID":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE"}],"titlePatterns":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimSuffix":" - Articles Example"},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCC2","isActive":false,"siteHostname":"articles.example","version":1},{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"definition":{"wholeTitle":{}},"trimPrefix":"TtH • Story • "},"id":"CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC","isActive":true,"siteHostname":"golden.example","version":1}],"urlRules":[{"createdAt":"1970-01-12T13:46:40.000Z","definition":{"sequence":{"locator":{"query":{"name":"chapter"}}}},"id":"DDDDDDDD-DDDD-DDDD-DDDD-DDDDDDDDDDDD","isCurrent":true,"origin":"readerTaught","siteHostname":"golden.example","version":1}],"workTypes":[{"canonicalID":"D0000001-0000-4000-8000-000000000001","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A1","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novel","stateRaw":"merged"},{"canonicalID":"00000000-0000-0000-0000-0000000000A1","createdAt":"1970-01-12T13:46:40.000Z","id":"00000000-0000-0000-0000-0000000000A2","modifiedAt":"1970-01-12T13:46:40.000Z","name":"novella","stateRaw":"merged"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000001-0000-4000-8000-000000000001","modifiedAt":"1970-01-01T00:00:00.000Z","name":"novel","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000002-0000-4000-8000-000000000002","modifiedAt":"1970-01-01T00:00:00.000Z","name":"webtoon","stateRaw":"active"},{"createdAt":"1970-01-01T00:00:00.000Z","id":"D0000003-0000-4000-8000-000000000003","modifiedAt":"1970-01-01T00:00:00.000Z","name":"article","stateRaw":"active"}],"works":[{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Twice Over","genericNotes":"","genreTags":[],"id":"D0000000-0000-4000-8000-000000000001","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Plain Work","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE2","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"finished","seriesID":"5E81E5A0-0000-4000-8000-000000000001","seriesPosition":1,"titleProvenance":"manual","typeName":"novel","verdict":"","workStatus":"finished","workTypeID":"00000000-0000-0000-0000-0000000000A2"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"An Article","genericNotes":"","genreTags":[],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEE3","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"reading","seriesID":"5E81E5A0-0000-4000-8000-000000000009","seriesPosition":4,"titleProvenance":"manual","verdict":"","workStatus":"ongoing"},{"createdAt":"1970-01-12T13:46:40.000Z","displayTitle":"Actual Title","genericNotes":"The guide is not what he seems.","genericNotesExtractionFingerprint":"15b785793033dc26edf6396b3f0e1c27aa1ffaa61043ff49f907a970319a0499","genreTags":["fantasy"],"id":"EEEEEEEE-EEEE-EEEE-EEEE-EEEEEEEEEEEE","lastParsedTitle":"Actual Title","modifiedAt":"1970-01-12T13:46:40.000Z","readingStatus":"abandoned","seriesID":"5E81E5A0-0000-4000-8000-000000000001","seriesPosition":2.5,"titleProvenance":"parsed","typeName":"novel","verdict":"Stalled three years in; I gave up waiting.","workStatus":"hiatus","workTypeID":"00000000-0000-0000-0000-0000000000A1"}]},"workCount":4}\ No newline at end of file
Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift Modified +72 / -41
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swiftindex 6786231..8e1fd01 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift@@ -74,9 +74,9 @@ struct FrozenLibraryPathTests {     ///     /// The *digit* advances with each marker generation — `configurable-work-types`     /// moved it from `"5"` to `"6"` (Q26), `character-extraction` from `"6"` to-    /// `"7"` (Q80), and it now reads `"12"`, the third two-character+    /// `"7"` (Q80), and it now reads `"13"`, the fourth two-character     /// generation. What is frozen is the shape and the filename beside it.-    private static let markerContents = "12\n"+    private static let markerContents = "13\n"      /// Everything a fresh app-role open is allowed to leave in the root, SQLite's     /// own `-wal`/`-shm` companions excluded. An extra entry here is a path@@ -279,17 +279,19 @@ 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 — V12 live, V11 frozen as+        /// The store schemas this package declares — V13 live, V12 frozen as         /// the `from` version of the one lightweight stage — the plan that         /// stages it, and the floor the recorded-version reading refuses below.         ///-        /// V5, V6, V7, V8, V9 and V10 went with the stages that named them,+        /// V5 through V11 went with the stages that named them,         /// each on `retire-migration-chain` Decision 6's population precondition         /// (Q2 of `drop-superseded-columns`, Q18 of `work-and-reading-status`,-        /// Q60 of `series-and-related-works`, Q15 of `work-creators`). V9+        /// Q60 of `series-and-related-works`, Q15 of `work-creators`, Q48 of+        /// `place-extraction`). V9         /// shipped in that feature's phase 1 with its stage retained (Q32) and-        /// went in the follow-up; V10 went in this bump's freeze commit, the-        /// owner having confirmed the population before it ran.+        /// went in the follow-up; V10 and V11 each went in the freeze commit of+        /// the bump that superseded them, the owner having confirmed the+        /// population before it ran.         ///         /// **No marker generation is named here any more.** `markerLaggingV4`,         /// `markerLaggingV5` and `markerLaggingV6` were the bootstrap states for@@ -299,18 +301,18 @@ struct FrozenLibraryPathTests {         /// both deliberately unversioned by name because they always mean the         /// current generation.         let declaresAStoreSchemaOrMarkerGeneration: Set<String> = [-            "AsterismSchemaV11", "AsterismSchemaV12",-            "AsterismV12MigrationPlan",+            "AsterismSchemaV12", "AsterismSchemaV13",+            "AsterismV13MigrationPlan",             "atOrAboveV5", "belowV5", "firstV5Major",         ]-        /// The archive format — 11/12, the one shape the app reads and writes,+        /// The archive format — 12/13, the one shape the app reads and writes,         /// plus the 2/2 URL-rule origin the store still names. These name a         /// serialization version, not a store schema, and they are accurate:-        /// `work-creators` Req 9.1 mints format 11 over schema 12,+        /// `place-extraction` Req 5.1 mints format 12 over schema 13,         /// and every record this generation carries is its own rather than one         /// an earlier generation froze.         ///-        /// Every earlier generation's **read and write path** is gone, 10/11+        /// Every earlier generation's **read and write path** is gone, 11/12         /// included, so every name that described one — the codecs, documents,         /// payloads, exporters, snapshot protocols, reference and shape         /// validators, per-generation planners, gates and materializers — is@@ -322,20 +324,22 @@ struct FrozenLibraryPathTests {         /// single format, and a digit in their names would be a digit describing         /// nothing.         let namesTheArchiveFormat: Set<String> = [-            "BackupV11Entry", "BackupV11Site", "BackupV11TitlePattern", "BackupV11URLRule",-            "BackupV11Work", "BackupV11WorkType", "BackupV11Membership", "BackupV11DistinctPair",-            "BackupV11Character", "BackupV11Codec", "BackupV11Series", "BackupV11Link",-            "BackupV11Document", "BackupV11ExportError", "BackupV11Exporter", "BackupV11Metadata",-            "BackupV11Payload", "BackupV11ReferenceValidator",-            "BackupV11SnapshotProviding", "BackupV11Suppression",-            "BackupV11Creator", "BackupV11CreatorRole", "BackupV11Credit",-            "backupV11Snapshot",+            "BackupV12Entry", "BackupV12Site", "BackupV12TitlePattern", "BackupV12URLRule",+            "BackupV12Work", "BackupV12WorkType", "BackupV12Membership", "BackupV12DistinctPair",+            "BackupV12Character", "BackupV12Codec", "BackupV12Series", "BackupV12Link",+            "BackupV12Document", "BackupV12ExportError", "BackupV12Exporter", "BackupV12Metadata",+            "BackupV12Payload", "BackupV12ReferenceValidator",+            "BackupV12SnapshotProviding", "BackupV12Suppression",+            "BackupV12Creator", "BackupV12CreatorRole", "BackupV12Credit",+            "BackupV12Place", "BackupV12PlaceSuppression",+            "backupV12Snapshot",             "importedV2", "importedV2Path",-            "mapV11EntryRecord", "mapV11SiteRecord", "mapV11TitlePatternRecord",-            "mapV11URLRuleRecord", "mapV11WorkRecord",-            "mapV11CharacterRecord", "mapV11SuppressionRecord",-            "mapV11CreatorRecords", "mapV11CreatorRoleRecords",-            "projectV11Payload",+            "mapV12EntryRecord", "mapV12SiteRecord", "mapV12TitlePatternRecord",+            "mapV12URLRuleRecord", "mapV12WorkRecord",+            "mapV12CharacterRecord", "mapV12SuppressionRecord",+            "mapV12PlaceRecord", "mapV12PlaceSuppressionRecord",+            "mapV12CreatorRecords", "mapV12CreatorRoleRecords",+            "projectV12Payload",         ]         /// The Entry identity-key generation, `EntryIdentityKeyV2Codec` /         /// `V3Codec`. A v2 key and a v3 key are different encodings of the same@@ -385,28 +389,29 @@ struct FrozenLibraryPathTests {         }         #expect(             declared.sorted() == [-                "AsterismSchemaV11", "AsterismSchemaV12",+                "AsterismSchemaV12", "AsterismSchemaV13",             ],             "the package declares versioned schemas \(declared); Req 3.3 allows only ones a plan references") -        let referenced = AsterismV12MigrationPlan.schemas.map { String(describing: $0) }+        let referenced = AsterismV13MigrationPlan.schemas.map { String(describing: $0) }         #expect(-            referenced == ["AsterismSchemaV11", "AsterismSchemaV12"],+            referenced == ["AsterismSchemaV12", "AsterismSchemaV13"],             "the plan references \(referenced), which is not the set of declared schemas")-        // One lightweight stage, and it purely **adds**: V11 → V12 adds three+        // One lightweight stage, and it purely **adds**: V12 → V13 adds two         // empty tables inside `ModelContainer.init` and no column at all, with         // no data pass behind it. The V8 stage retired with the snapshot it         // named (Q18); the V9 one shipped retained (Q32 of         // `series-and-related-works`) and went in the follow-up (Q60); the V10-        // one went in this bump's own freeze commit (Q15).+        // and V11 ones each went in the freeze commit of the bump that+        // superseded them (Q15 of `work-creators`, Q48 of `place-extraction`).         #expect(-            AsterismV12MigrationPlan.stages.count == 1,-            "the plan stages \(AsterismV12MigrationPlan.stages.count) migrations; V11 → V12 is one")-        #expect(-            AsterismSchemaV11.versionIdentifier == Schema.Version(11, 0, 0),-            "the frozen snapshot's version stamp is the `from` side every V11 store is matched on")+            AsterismV13MigrationPlan.stages.count == 1,+            "the plan stages \(AsterismV13MigrationPlan.stages.count) migrations; V12 → V13 is one")         #expect(             AsterismSchemaV12.versionIdentifier == Schema.Version(12, 0, 0),+            "the frozen snapshot's version stamp is the `from` side every V12 store is matched on")+        #expect(+            AsterismSchemaV13.versionIdentifier == Schema.Version(13, 0, 0),             "the live schema's version stamp is what every recorded store is compared against")     } @@ -599,6 +604,10 @@ struct FrozenLibraryPathTests {             // was confirmed on marker `"11"` on 2026-09-07, *before* the freeze             // ran rather than a commit after it (Q15).             "AsterismV11MigrationPlan", "AsterismSchemaV10",+            // Retired by `place-extraction` (T-2276), in the commit that froze+            // V12, on the same precondition: every device was confirmed on+            // marker `"12"` on 2026-09-10 before the freeze ran (Q48).+            "AsterismV12MigrationPlan", "AsterismSchemaV11",         ]         for file in try coreSourceFiles() {             let text = try String(contentsOf: file, encoding: .utf8)@@ -685,17 +694,36 @@ struct FrozenLibraryPathTests {         "Creator", "CreatorRole", "WorkCredit",     ] +    /// V13's two record types, pinned on the same grounds again: the share sheet+    /// is a stated non-goal of `place-extraction` (Q5), so the extension neither+    /// reads nor writes a place, and a write would need one of these names.+    /// `Place` is matched at a word boundary, so `PlaceSuppression` does not+    /// satisfy it and `placeholder` does not trip it.+    private static let placeStorageSymbols = [+        "Place", "PlaceSuppression",+    ]++    /// The two record-surface repository files, renamed from+    /// `LibraryRepository+CharacterExtraction.swift` / `+CharacterEditing.swift`+    /// when the store became generic over `RecordRow` (design §Store generics).+    /// They are read for premise 1 only: what the extension is pinned against is+    /// the record *types* above, which `Models.swift` declares.+    private static let appOnlyRecordFiles: [String] = [+        "LibraryRepository+RecordExtraction.swift", "LibraryRepository+RecordEditing.swift",+    ]+     private static let extensionRoots = [         "Asterism/AsterismShareExtension",         "Asterism/AsterismShareExtensionMac",     ] -    @Test("The share extension names nothing from the series, link or credit surfaces")+    @Test("The share extension names nothing from the series, link, credit or place surfaces")     func theExtensionLinksNoSeriesOrLinkSymbol() throws {         // Premise 1: every pinned symbol is really declared where this test says         // it is. A stale list would pass by naming nothing.         var declared: Set<String> = []-        for name in Self.appOnlySeriesAndLinkFiles + Self.appOnlyCreatorFiles {+        for name in Self.appOnlySeriesAndLinkFiles + Self.appOnlyCreatorFiles+                    + Self.appOnlyRecordFiles {             declared.formUnion(                 declaredIdentifiers(                     in: try String(@@ -706,7 +734,8 @@ struct FrozenLibraryPathTests {                 in: try String(                     contentsOf: Self.coreSources.appending(path: "Models.swift"), encoding: .utf8)))         let missing = (Self.appOnlySeriesAndLinkSymbols + Self.seriesAndLinkStorageSymbols-                       + Self.creatorStorageSymbols + Self.appOnlyCreatorSymbols)+                       + Self.creatorStorageSymbols + Self.appOnlyCreatorSymbols+                       + Self.placeStorageSymbols)             .filter { !declared.contains($0) }         #expect(             missing.isEmpty,@@ -723,7 +752,8 @@ struct FrozenLibraryPathTests {          // The pin itself.         for name in Self.appOnlySeriesAndLinkSymbols + Self.seriesAndLinkStorageSymbols-                    + Self.creatorStorageSymbols + Self.appOnlyCreatorSymbols {+                    + Self.creatorStorageSymbols + Self.appOnlyCreatorSymbols+                    + Self.placeStorageSymbols {             let word = try Regex("\\b\(name)\\b")             for source in sources {                 let relativePath = source.path.replacingOccurrences(@@ -732,8 +762,9 @@ struct FrozenLibraryPathTests {                     source.text.firstMatch(of: word) == nil,                     """                     \(relativePath) names \(name): the share extension creates, changes and \-                    removes no series, membership, link, creator, role or credit \-                    (`series-and-related-works` Req 11.5, `work-creators` Req 10.7)+                    removes no series, membership, link, creator, role, credit or place \+                    (`series-and-related-works` Req 11.5, `work-creators` Req 10.7, \+                    `place-extraction` Q5)                     """)             }         }
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 d7d692e..db25533 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupFetchTests.swift@@ -260,12 +260,12 @@ private final class GroupStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismGroupFetch-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.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 55a6d02..f35d5c3 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/GroupOrderingTests.swift@@ -707,12 +707,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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.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 a6df2e7..a8479d6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/IdentityResolutionTests.swift@@ -284,12 +284,12 @@ private final class ResolutionStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])     } 
Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift Modified +51 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swiftindex 0dd483d..8df4aef 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryGraphBaselineTests.swift@@ -316,11 +316,14 @@ extension LibraryRepository {             let creators = try context.fetch(FetchDescriptor<Creator>())             let creatorRoles = try context.fetch(FetchDescriptor<CreatorRole>())             let credits = try context.fetch(FetchDescriptor<WorkCredit>())+            let places = try context.fetch(FetchDescriptor<Place>())+            let placeSuppressions = try context.fetch(FetchDescriptor<PlaceSuppression>())             return LibraryGraphSerializer.dump(                 sites: sites, entries: entries, works: works, patterns: patterns, rules: rules,                 workTypes: workTypes, memberships: memberships, pairs: pairs,                 series: series, links: links,-                creators: creators, creatorRoles: creatorRoles, credits: credits)+                creators: creators, creatorRoles: creatorRoles, credits: credits,+                places: places, placeSuppressions: placeSuppressions)         }     } }@@ -341,7 +344,8 @@ enum LibraryGraphSerializer {         patterns: [TitlePattern], rules: [URLRulePattern], workTypes: [WorkTypeEntity],         memberships: [WorkSiteMembership], pairs: [WorkDistinctPair],         series: [Series], links: [WorkLink],-        creators: [Creator], creatorRoles: [CreatorRole], credits: [WorkCredit]+        creators: [Creator], creatorRoles: [CreatorRole], credits: [WorkCredit],+        places: [Place], placeSuppressions: [PlaceSuppression]     ) -> String {         var lines: [String] = [             "# Asterism library graph baseline — Requirement 2.15",@@ -385,13 +389,23 @@ enum LibraryGraphSerializer {             "# with epoch timestamps here. Re-recorded by adding the three counts and",             "# the three section markers by hand and reviewing the diff line by line, not",             "# by regenerating the file.",-            "format 9",+            "# format 10 is schema V13 (place-extraction, T-2276): the dump gains a",+            "# place and a placeSuppression section and their two counts. No work line",+            "# and no other section changes at all — V13 is the second stage that adds",+            "# only tables — which is the point: the two empty sections are the",+            "# baseline's own statement that the V12 -> V13 stage leaves every existing",+            "# row exactly as it found it. Neither table has a seeded default, so both",+            "# are empty here where the role section is not. Re-recorded by adding the",+            "# two counts and the two section markers by hand and reviewing the diff",+            "# line by line, not by regenerating the file.",+            "format 10",             "counts entries=\(entries.count) works=\(works.count) sites=\(sites.count) "                 + "titlePatterns=\(patterns.count) urlRulePatterns=\(rules.count) "                 + "workTypes=\(workTypes.count) memberships=\(memberships.count) "                 + "distinctPairs=\(pairs.count) series=\(series.count) links=\(links.count) "                 + "creators=\(creators.count) creatorRoles=\(creatorRoles.count) "-                + "credits=\(credits.count)",+                + "credits=\(credits.count) places=\(places.count) "+                + "placeSuppressions=\(placeSuppressions.count)",         ]          for site in sites.sorted(by: { $0.hostname < $1.hostname }) {@@ -592,6 +606,39 @@ enum LibraryGraphSerializer {                 ]))         } +        // V13's two additions (T-2276). Neither carries a relationship either, so+        // each has a forward section and no inverse one: a place and a place+        // suppression name their work by column, and the fact blob travels as+        // bytes exactly as a character's does.+        for place in places.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {+            lines.append(+                "place " + fields([+                    ("id", place.id.uuidString),+                    ("workID", place.workID.uuidString),+                    ("name", quoted(place.name)),+                    ("nameKey", quoted(place.nameKey)),+                    ("aliases", "[" + place.aliases.map(quoted).joined(separator: ",") + "]"),+                    ("note", quoted(place.note)),+                    ("factsData", optionalQuoted(place.factsData.map(canonicalJSON))),+                    ("createdAt", timestamp(place.createdAt)),+                    ("modifiedAt", timestamp(place.modifiedAt)),+                ]))+        }+        for row in placeSuppressions.sorted(by: { $0.id.uuidString < $1.id.uuidString }) {+            lines.append(+                "placeSuppression " + fields([+                    ("id", row.id.uuidString),+                    ("workID", row.workID.uuidString),+                    ("kindRaw", quoted(row.kindRaw)),+                    ("nameKey", quoted(row.nameKey)),+                    ("sourceKindRaw", optionalQuoted(row.sourceKindRaw)),+                    ("sourceEntryID", optional(row.sourceEntryID?.uuidString)),+                    ("evidence", optionalQuoted(row.evidence)),+                    ("statusRaw", quoted(row.statusRaw)),+                    ("actionAt", timestamp(row.actionAt)),+                ]))+        }+         // The inverse side of every relationship. `Site.entries` and         // `Site.workMemberships` are internal by design (Q17 — traversing them         // faults every record for a hostname), which is exactly why a test is
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 5fe0b12..f5a3533 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryToleranceScanTests.swift@@ -367,12 +367,12 @@ private final class ToleranceScanStore {     }      private static func makeContainer(at directory: URL) throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         return try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.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 c7ea930..a8a5180 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/LibraryValidatorToleranceTests.swift@@ -447,12 +447,12 @@ private final class ValidatorStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismValidatorTolerance-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.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 092dbc1..c861a83 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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let container = try ModelContainer(             for: schema,             configurations: [
Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swiftindex 714d837..ae07643 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4BulkChunkPerformanceTests.swift@@ -140,7 +140,7 @@ struct M4BulkChunkPerformanceTests { /// hostname, every one of them carrying the Site relationship whose assignment /// is the cost being measured. private enum M4ChunkFixture {-    static func exportedFixturePayload() async throws -> BackupV11Payload {+    static func exportedFixturePayload() async throws -> BackupV12Payload {         let root = FileManager.default.temporaryDirectory             .appending(                 path: "asterism-m4-chunk-source-\(UUID().uuidString)", directoryHint: .isDirectory)@@ -154,7 +154,7 @@ private enum M4ChunkFixture {         let repository = LibraryRepository.makeRepository(             configuration, container, .m4, SystemRepositoryClock(), ModelContextSaveStrategy())         try await repository.seedM4PerformanceFixture()-        let payload = try await repository.backupV11Snapshot()+        let payload = try await repository.backupV12Snapshot()         withExtendedLifetime(container) {}         return payload     }
Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swiftindex 369bc68..45aea81 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift@@ -274,7 +274,7 @@ struct M4DuplicateScalePerformanceTests {     /// records what the projection alone costs so the claim is a reading rather     /// than an argument.     ///-    /// The *projection* is timed, not `BackupV11Exporter.export`: the encode,+    /// The *projection* is timed, not `BackupV12Exporter.export`: the encode,     /// the decode-validation and the file write dominate and none of them     /// changed.     @Test("Backup projection over a duplicate-free library (Q116, informational)")@@ -283,7 +283,7 @@ struct M4DuplicateScalePerformanceTests {         let repository = try await store.openApp()          let measured = try await measureDistributionAsync(iterations: 5) {-            _ = try await repository.backupV11Snapshot()+            _ = try await repository.backupV12Snapshot()         }         reportPerformance("backup-projection-duplicate-free", measured)     }
Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift Modified +64 / -34
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swiftindex e6e62be..f50aeea 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift@@ -46,10 +46,12 @@ struct M4ScalePerformanceTests {     private let completePreviewBudget = Duration.seconds(1)     private let captureBudget = Duration.milliseconds(100)     private let extensionOpenBudget = Duration.seconds(1)-    /// `character-ranking` Req 4.3: the budget, and the regression ceiling that-    /// is asserted whatever happens to the budget (Q27).-    private let characterRankingBudget = Duration.milliseconds(10)-    private let characterRankingCeiling = Duration.milliseconds(50)+    /// `character-ranking` Req 4.3, and `place-extraction` Req 6.2 citing the+    /// same numbers: the budget, and the regression ceiling that is asserted+    /// whatever happens to the budget (Q27). One pair for both record kinds —+    /// the ranking is one body — with the label kept per arm.+    private let recordRankingBudget = Duration.milliseconds(10)+    private let recordRankingCeiling = Duration.milliseconds(50)     private let iterations = 20      // MARK: - Preview budgets (driving ComposedTeachingProjectionPlanner)@@ -184,11 +186,11 @@ struct M4ScalePerformanceTests {     /// Both the index and the fact-blob decode are hoisted out of the timed     /// region, and for the same reason: a read path builds the index once, and     /// under Decision 4 it decodes each character's facts once and shares them-    /// with `characterPresentations`. What is timed is `order` — the scoring+    /// with `recordPresentations`. What is timed is `order` — the scoring     /// and the sort, which is the "ranking function alone" Req 4.3 budgets.     ///     /// Timing `rank` instead measured 0.0399 s, of which 0.0364 s was-    /// `CharacterAuthoredContent.facts` decoding and canonically ordering the+    /// `RecordAuthoredContent.facts` decoding and canonically ordering the     /// 200 stored blobs and **0.0016 s** the arithmetic. That is a `Codable`     /// pass, not a ranking cost, and Decision 4 removed the second one rather     /// than recording a permanent known issue against a budget it was never@@ -199,20 +201,40 @@ struct M4ScalePerformanceTests {     /// nothing to compare against.     @Test("Ranking 200 characters × 50 facts over 500 entries ≤ 10 ms (4.3)")     func characterRankingAtScale() {-        let fixture = CharacterRankingFixture()-        let decoded = CharacterRanking.decode(fixture.groups)+        rankingAtScale(RecordRankingFixture<CharacterRecord>(), label: "character-ranking-200x50")+    }++    /// `place-extraction` Req 6.2: the same ranking function invoked over+    /// places, against the same budget and the same ceiling.+    ///+    /// A separate arm rather than a parameterised one, because it is a separate+    /// requirement: the two lists are ranked independently (that spec's Q14), so+    /// a regression in one must be readable without the other's number beside+    /// it. The fixture is the same shape over `Place`, which is what makes the+    /// two numbers comparable — the generic ranker is one implementation, and+    /// what this arm measures is that the second conformance costs what the+    /// first does.+    @Test("Ranking 200 places × 50 facts over 500 entries ≤ 10 ms (6.2)")+    func placeRankingAtScale() {+        rankingAtScale(RecordRankingFixture<Place>(), label: "place-ranking-200x50")+    }++    private func rankingAtScale<Row: RecordRow>(+        _ fixture: RecordRankingFixture<Row>, label: String+    ) {+        let decoded = RecordRanking.decode(fixture.groups)         #expect(             decoded.count == 200 && decoded.allSatisfy { $0.facts.count == 50 },             "the fixture must present the Req 4.3 shape, or the measurement is of nothing")          let measured = measureDistribution(iterations: iterations) {-            _ = CharacterRanking.order(decoded, index: fixture.index)+            _ = RecordRanking.order(decoded, index: fixture.index)         }-        expectWithinBudget("character-ranking-200x50", measured, characterRankingBudget)+        expectWithinBudget(label, measured, recordRankingBudget)         // The regression ceiling of Q27, asserted alongside the budget rather         // than in place of it: the budget is what the requirement asks for, the         // ceiling is what catches a drift that has not yet reached it.-        expectWithinCeiling("character-ranking-200x50", measured, characterRankingCeiling)+        expectWithinCeiling(label, measured, recordRankingCeiling)     }      // MARK: - Extension open + validate budget (Req 8.5)@@ -733,25 +755,32 @@ final class M4ConsolidationStore {  // MARK: - Fixture: the Req 4.3 ranking shape -/// 200 characters with 50 facts each over a 500-entry story-position index —-/// the shape `character-ranking` Req 4.3 budgets.+/// 200 records with 50 facts each over a 500-entry story-position index — the+/// shape `character-ranking` Req 4.3 budgets, and `place-extraction` Req 6.2+/// after it.+///+/// In-memory rather than seeded on disk: the ranker takes record groups and an+/// index, never a store, so a fixture that opened a container would time+/// SwiftData rather than the ranking. The rows are unmanaged for the same+/// reason, and a place's `workID` is left at the fresh UUID `make(work: nil)`+/// mints — ownership takes no part in the ranking, and nothing here is+/// inserted into a context. ///-/// In-memory rather than seeded on disk: the ranker takes character groups and-/// an index, never a store, so a fixture that opened a container would time-/// SwiftData rather than the ranking. The rows are unmanaged `CharacterRecord`s-/// for the same reason.+/// One fixture over `RecordRow` rather than one per kind (Decision 3): the two+/// arms are only comparable while they measure the same shape, and a copy would+/// drift the moment one of them was tuned. /// /// The distribution is deliberately mixed, so the measurement covers every /// bucket kind the scorer can build: two generic-notes facts and two dangling-/// ones per character, twenty clustered over five chapters — the dense-chapter+/// ones per record, twenty clustered over five chapters — the dense-chapter /// shape — and the remaining twenty-six spread across the work.-struct CharacterRankingFixture {-    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)+struct RecordRankingFixture<Row: RecordRow> {+    private static var epoch: Date { Date(timeIntervalSince1970: 1_800_000_000) }      let index: StoryPositionIndex-    let groups: [UUID: CharacterGroup]+    let groups: [UUID: RecordGroup<Row>] -    init(entryCount: Int = 500, characterCount: Int = 200, factsPerCharacter: Int = 50) {+    init(entryCount: Int = 500, recordCount: Int = 200, factsPerRecord: Int = 50) {         let entryIDs = (0..<entryCount).map { _ in UUID() }         let inputs = entryIDs.enumerated().map { position, id in             StoryPositionIndex.EntryInput(@@ -766,28 +795,29 @@ struct CharacterRankingFixture {         }         index = StoryPositionIndex(entries: inputs) -        var built: [UUID: CharacterGroup] = [:]-        built.reserveCapacity(characterCount)-        for character in 0..<characterCount {+        var built: [UUID: RecordGroup<Row>] = [:]+        built.reserveCapacity(recordCount)+        for record in 0..<recordCount {             let id = UUID()-            let name = "Character \(character)"-            let key = CharacterNameKey.normalize(name)-            let facts = (0..<factsPerCharacter).map { ordinal -> CharacterFact in+            let name = "Record \(record)"+            let key = RecordNameKey.normalize(name)+            let facts = (0..<factsPerRecord).map { ordinal -> RecordFact in                 let source: SourceRef                 switch ordinal {                 case 0, 1: source = .genericNotes                 case 2, 3: source = .entry(UUID())  // dangling-                case ..<24: source = .entry(entryIDs[(character * 7 + ordinal % 5) % entryCount])-                default: source = .entry(entryIDs[(character * 37 + ordinal * 11) % entryCount])+                case ..<24: source = .entry(entryIDs[(record * 7 + ordinal % 5) % entryCount])+                default: source = .entry(entryIDs[(record * 37 + ordinal * 11) % entryCount])                 }-                return CharacterFact(+                return RecordFact(                     statement: "statement \(ordinal) about \(name)",                     quote: "quote \(ordinal) about \(name)",                     nameKey: key, source: source)             }-            let record = CharacterRecord(-                id: id, name: name, nameKey: key, facts: facts, timestamp: Self.epoch)-            built[id] = LibraryRepository.characterGroup(id: id, rows: [record])+            let row = Row.make(+                id: id, name: name, nameKey: key, aliases: [], note: "", facts: facts,+                timestamp: Self.epoch, work: nil)+            built[id] = LibraryRepository.recordGroup(id: id, rows: [row])         }         groups = built     }
Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift Modified +294 / -18
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swiftindex 5c5649f..07a8d55 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/M5RepositoryTestSupport.swift@@ -147,7 +147,7 @@ struct M5SeedCharacter: Sendable {     var nameKey: String?     var aliases: [String] = []     var note: String = ""-    var facts: [CharacterFact] = []+    var facts: [RecordFact] = []     var workID: UUID?     /// Which row of `workID`'s group this row points at, in seeding order.     var workRowIndex: Int = 0@@ -156,7 +156,7 @@ struct M5SeedCharacter: Sendable {      init(         id: UUID, name: String, nameKey: String? = nil, aliases: [String] = [],-        note: String = "", facts: [CharacterFact] = [], workID: UUID? = nil,+        note: String = "", facts: [RecordFact] = [], workID: UUID? = nil,         workRowIndex: Int = 0, createdAt: Date = M5Fixture.epoch,         modifiedAt: Date = M5Fixture.epoch     ) {@@ -173,8 +173,138 @@ struct M5SeedCharacter: Sendable {     } } +/// One `Place` **row**. Two rows sharing `id` are one split group; giving them+/// different authored content is the torn shape Req 5.3 is about.+///+/// The shape is `M5SeedCharacter`'s with **`workID` in place of the row index**:+/// a place names its work by column rather than through a relationship, so there+/// is no row of a split work group to point at. A `workID` naming no `Work` is+/// the tolerated orphan of Req 5.5, which no write path produces and which a+/// suite seeds by handing in a fresh UUID.+struct M5SeedPlace: Sendable {+    var id: UUID+    var name: String+    var nameKey: String?+    var aliases: [String] = []+    var note: String = ""+    var facts: [RecordFact] = []+    var workID: UUID+    var createdAt: Date = M5Fixture.epoch+    var modifiedAt: Date = M5Fixture.epoch++    init(+        id: UUID, name: String, nameKey: String? = nil, aliases: [String] = [],+        note: String = "", facts: [RecordFact] = [], workID: UUID,+        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.createdAt = createdAt+        self.modifiedAt = modifiedAt+    }+}++/// One record row of **either** kind, for a suite parameterised over+/// `RecordKind`.+///+/// The union of the two seed shapes: `workID` is optional, because a character+/// with no work is Req 6.7's sync orphan and a place with no resolvable work is+/// Req 5.5's tolerated one — two routes to the same inert state, which is+/// exactly what a parameterised suite wants to say once.+struct M5SeedRecord: Sendable {+    var id: UUID+    var name: String+    var nameKey: String?+    var aliases: [String] = []+    var note: String = ""+    var facts: [RecordFact] = []+    var workID: UUID?+    var createdAt: Date = M5Fixture.epoch+    var modifiedAt: Date = M5Fixture.epoch++    init(+        id: UUID, name: String, nameKey: String? = nil, aliases: [String] = [],+        note: String = "", facts: [RecordFact] = [], workID: UUID? = nil,+        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.createdAt = createdAt+        self.modifiedAt = modifiedAt+    }+}++/// One suppression row of either kind, on the same terms.+struct M5SeedRecordSuppression: Sendable {+    var id: UUID = 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+    }+}++/// One `PlaceSuppression` row — `M5SeedSuppression`'s shape with a non-optional+/// `workID`, for `M5SeedPlace`'s reason.+struct M5SeedPlaceSuppression: 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,+        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+    }+}+ /// A suppression row as a value, so a suite can assert about one outside the-/// repository actor.+/// repository actor. One value type for both kinds: the fields are the same, and+/// the `workID` a place states directly is the same answer the character row's+/// relationship gives. struct M5SuppressionSnapshot: Sendable, Equatable {     var id: UUID     var workID: UUID?@@ -195,6 +325,17 @@ struct M5SuppressionSnapshot: Sendable, Equatable {         status = row.status         actionAt = row.actionAt     }++    init(_ row: PlaceSuppression) {+        id = row.id+        workID = row.workID+        kind = row.kind+        nameKey = row.nameKey+        source = row.source+        evidence = row.evidence+        status = row.status+        actionAt = row.actionAt+    } }  /// One `WorkSiteMembership` row as a value — every field an archive record can@@ -239,23 +380,27 @@ struct M5DistinctPairSnapshot: Sendable, Equatable {     } } -/// 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 {+/// A record row as a value, so a suite can assert about one outside the+/// repository actor. Row-level, the owning work included, which is what an+/// archive round-trip and an orphan test both need to see.+///+/// One shape for both kinds, over `RecordRow`: `ownerWorkID` answers the+/// relationship for a character and the column for a place, which is the whole+/// difference between the tables here.+struct M5RecordSnapshot: Sendable, Equatable {     var id: UUID     var workID: UUID?     var name: String     var nameKey: String     var aliases: [String]     var note: String-    var facts: [CharacterFact]+    var facts: [RecordFact]     var createdAt: Date     var modifiedAt: Date -    init(_ row: CharacterRecord) {-        id = row.id-        workID = row.work?.id+    init<Row: RecordRow>(_ row: Row) {+        id = row.recordID+        workID = row.ownerWorkID         name = row.name         nameKey = row.nameKey         aliases = row.aliases@@ -299,7 +444,8 @@ extension LibraryRepository {     /// Writes the given rows into an empty (or not) store in one save.     func seedM5Rows(         sites: [M5SeedSite] = [], works: [M5SeedWork] = [], entries: [M5SeedEntry] = [],-        characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = []+        characters: [M5SeedCharacter] = [], suppressions: [M5SeedSuppression] = [],+        places: [M5SeedPlace] = [], placeSuppressions: [M5SeedPlaceSuppression] = []     ) async throws {         try await withLockedContext(mode: .exclusive, operation: "seeding M5 test rows") { context in             var siteRows: [String: Site] = [:]@@ -417,7 +563,7 @@ extension LibraryRepository {                 let character = CharacterRecord(                     id: seed.id,                     name: seed.name,-                    nameKey: seed.nameKey ?? CharacterNameKey.normalize(seed.name),+                    nameKey: seed.nameKey ?? RecordNameKey.normalize(seed.name),                     aliases: seed.aliases,                     note: seed.note,                     facts: seed.facts,@@ -435,16 +581,42 @@ extension LibraryRepository {                 context.insert(row)                 row.work = try workRow(seed.workID, index: 0)             }++            // V13's two tables. No `workRow` lookup: a place names its work by+            // column, so the seed's identifier is written straight through —+            // including one that resolves to nothing, which is the orphan of+            // Req 5.5.+            for seed in places {+                let place = Place(+                    id: seed.id,+                    name: seed.name,+                    nameKey: seed.nameKey ?? RecordNameKey.normalize(seed.name),+                    aliases: seed.aliases,+                    note: seed.note,+                    facts: seed.facts,+                    timestamp: seed.createdAt,+                    workID: seed.workID)+                place.modifiedAt = seed.modifiedAt+                context.insert(place)+            }++            for seed in placeSuppressions {+                let row = PlaceSuppression(+                    id: seed.id, workID: seed.workID, kind: seed.kind, nameKey: seed.nameKey,+                    source: seed.source, evidence: seed.evidence, status: seed.status,+                    actionAt: seed.actionAt)+                context.insert(row)+            }             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] {+    func m5CharacterRows(id: UUID) async throws -> [RecordAuthoredContent] {         try await withLockedContext(mode: .shared, operation: "reading character rows") { context in-            GroupOrdering.sortedCharacterRows(-                try LibraryRepository.characterRows(ids: [id], context: context)[id] ?? []+            GroupOrdering.sortedRecordRows(+                try LibraryRepository.recordRows(CharacterRecord.self, ids: [id], context: context)[id] ?? []             ).map(GroupOrdering.authoredContent(of:))         }     }@@ -458,6 +630,29 @@ extension LibraryRepository {         }     } +    /// Every **place** row for one application UUID, in representative order, as+    /// values — the place twin of `m5CharacterRows(id:)`, reading through the+    /// same generic fetch so an orphan is reachable by id even though+    /// `rows(of:)` cannot see it.+    func m5PlaceRows(id: UUID) async throws -> [RecordAuthoredContent] {+        try await withLockedContext(mode: .shared, operation: "reading place rows") { context in+            GroupOrdering.sortedRecordRows(+                try LibraryRepository.recordRows(Place.self, ids: [id], context: context)[id] ?? []+            ).map(GroupOrdering.authoredContent(of:))+        }+    }++    /// The place-suppression rows in the store, as values, in a stable order.+    func m5PlaceSuppressionRows() async throws -> [M5SuppressionSnapshot] {+        try await withLockedContext(+            mode: .shared, operation: "reading place suppressions"+        ) { context in+            try context.fetch(FetchDescriptor<PlaceSuppression>())+                .map(M5SuppressionSnapshot.init)+                .sorted { $0.id.uuidString < $1.id.uuidString }+        }+    }+     /// Every membership row in the store, as values, in a stable order.     func m5MembershipRows() async throws -> [M5MembershipSnapshot] {         try await withLockedContext(mode: .shared, operation: "reading memberships") { context in@@ -477,10 +672,91 @@ extension LibraryRepository {     }      /// Every character row in the store, as values, in a stable order.-    func m5AllCharacters() async throws -> [M5CharacterSnapshot] {+    func m5AllCharacters() async throws -> [M5RecordSnapshot] {         try await withLockedContext(mode: .shared, operation: "reading characters") { context in             try context.fetch(FetchDescriptor<CharacterRecord>())-                .map(M5CharacterSnapshot.init)+                .map(M5RecordSnapshot.init)+                .sorted { $0.id.uuidString < $1.id.uuidString }+        }+    }++    /// Seeds record rows and suppressions of **one kind**, for a suite+    /// parameterised over `RecordKind`.+    ///+    /// A nil `workID` means the same thing on both sides — no work resolves —+    /// though the tables reach it differently: the character relationship is+    /// left unset, and the place column keeps the fresh UUID that resolves to+    /// nothing (Q67).+    func seedM5Records(+        _ kind: RecordKind,+        records: [M5SeedRecord] = [],+        suppressions: [M5SeedRecordSuppression] = []+    ) async throws {+        switch kind {+        case .character:+            try await seedM5Rows(+                characters: records.map {+                    M5SeedCharacter(+                        id: $0.id, name: $0.name, nameKey: $0.nameKey, aliases: $0.aliases,+                        note: $0.note, facts: $0.facts, workID: $0.workID,+                        createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)+                },+                suppressions: suppressions.map {+                    M5SeedSuppression(+                        id: $0.id, workID: $0.workID, kind: $0.kind, nameKey: $0.nameKey,+                        source: $0.source, evidence: $0.evidence, status: $0.status,+                        actionAt: $0.actionAt)+                })+        case .place:+            try await seedM5Rows(+                places: records.map {+                    M5SeedPlace(+                        id: $0.id, name: $0.name, nameKey: $0.nameKey, aliases: $0.aliases,+                        note: $0.note, facts: $0.facts, workID: $0.workID ?? UUID(),+                        createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)+                },+                placeSuppressions: suppressions.map {+                    M5SeedPlaceSuppression(+                        id: $0.id, workID: $0.workID ?? UUID(), kind: $0.kind,+                        nameKey: $0.nameKey, source: $0.source, evidence: $0.evidence,+                        status: $0.status, actionAt: $0.actionAt)+                })+        }+    }++    /// Every row of one record id, of one kind.+    func m5RecordRows(_ kind: RecordKind, id: UUID) async throws -> [RecordAuthoredContent] {+        switch kind {+        case .character: try await m5CharacterRows(id: id)+        case .place: try await m5PlaceRows(id: id)+        }+    }++    /// Every row of one kind in the store, as values, in a stable order —+    /// including an orphan, which is what a "nothing was written" assertion+    /// after a refusal has to be able to see.+    func m5AllRecords(_ kind: RecordKind) async throws -> [M5RecordSnapshot] {+        switch kind {+        case .character: try await m5AllCharacters()+        case .place: try await m5AllPlaces()+        }+    }++    /// One kind's suppression rows, as values, in a stable order.+    func m5RecordSuppressionRows(_ kind: RecordKind) async throws -> [M5SuppressionSnapshot] {+        switch kind {+        case .character: try await m5SuppressionRows()+        case .place: try await m5PlaceSuppressionRows()+        }+    }++    /// Every place row in the store, as values, in a stable order — the place+    /// twin of `m5AllCharacters()`, and the only reader that can see an orphan's+    /// `workID` and a row's timestamps together.+    func m5AllPlaces() async throws -> [M5RecordSnapshot] {+        try await withLockedContext(mode: .shared, operation: "reading places") { context in+            try context.fetch(FetchDescriptor<Place>())+                .map(M5RecordSnapshot.init)                 .sorted { $0.id.uuidString < $1.id.uuidString }         }     }
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift Modified +35 / -34
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swiftindex 7d0290c..ff2946b 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerContractTests.swift@@ -12,16 +12,17 @@ import Testing /// constructs a container. /// /// **The app opens two generations and the extension one.**-/// `work-creators` publishes `"12"` and holds `"11"` in-/// `appOpenableMarkerVersions` as the generation V12 upgrades from (Req 11.1):-/// the app opens it — the lightweight stage adds three empty tables and no+/// `place-extraction` publishes `"13"` and holds `"12"` in+/// `appOpenableMarkerVersions` as the generation V13 upgrades from (Req 5.6):+/// the app opens it — the lightweight stage adds two empty tables and no /// column at all inside `ModelContainer.init` — validates the store and-/// republishes at `"12"`, with no data pass and no reconciler. The extension-/// refuses `"11"` outright, because it holds only a shared lock and must never-/// convert or write. `"4"`–`"10"` stay retired (`data-model-cleanups`+/// republishes at `"13"`, with no data pass and no reconciler. The extension+/// refuses `"12"` outright, because it holds only a shared lock and must never+/// convert or write. `"4"`–`"11"` stay retired (`data-model-cleanups` /// Decision 2, Q2 of `drop-superseded-columns` for `"7"`, Q18 of /// `work-and-reading-status` for `"8"`, Q32 of `series-and-related-works` for-/// `"9"`, Q15 here for `"10"`) and are refused by both.+/// `"9"`, Q15 of `work-creators` for `"10"`, Q48 here for `"11"`) and are+/// refused by both. /// /// The extension's refusal therefore **forks** (Req 2.3), as it did before /// Decision 2 collapsed it: a generation the app opens is resolvable by opening@@ -52,7 +53,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-    /// `"12"` directly (Q26).+    /// `"13"` directly (Q26).     private func makeReadyLibrary(_ configuration: LibraryConfiguration) async throws {         _ = try await LibraryRepository.openForApp(configuration)     }@@ -91,41 +92,41 @@ struct MarkerContractTests {      // MARK: - App side accepts one generation -    @Test("The app opens a library marked \"12\"")+    @Test("The app opens a library marked \"13\"")     func appAcceptsTheCurrentMarkerVersion() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        #expect(try markerContent(cfg) == "12",+        #expect(try markerContent(cfg) == "13",                 "an empty store has nothing to bring forward, so it is certified at birth (Q26)")          let (current, _) = try await LibraryRepository.openForApp(cfg)         #expect(current == .ready(.seededEmpty), "a certified library opens in the app")-        #expect(try markerContent(cfg) == "12", "and the open leaves the marker as it found it")+        #expect(try markerContent(cfg) == "13", "and the open leaves the marker as it found it")     } -    /// Req 11.1: the previous generation is *opened*, not refused — the stage-    /// adds three empty tables on the way in, and the app republishes at the+    /// Req 5.6: the previous generation is *opened*, not refused — the stage+    /// adds two empty tables on the way in, and the app republishes at the     /// current generation once the store has validated.-    @Test("The app opens a library marked \"11\" and republishes it at \"12\"")+    @Test("The app opens a library marked \"12\" and republishes it at \"13\"")     func appUpgradesTheLaggingGeneration() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        try writeMarker(cfg, "11\n")+        try writeMarker(cfg, "12\n")          let (result, repository) = try await LibraryRepository.openForApp(cfg)         await repository.shutdown()          #expect(result == .ready(.seededEmpty))-        #expect(try markerContent(cfg) == "12",+        #expect(try markerContent(cfg) == "13",                 "the marker moves only after the converted store has validated")     }      /// The retired generations sit in this list beside the digits no build ever-    /// published, which is the point of Decision 2: `"4"` through `"10"` are+    /// published, which is the point of Decision 2: `"4"` through `"11"` are     /// now exactly as openable as `"45"`.     @Test("The app fails closed on every marker version it does not open",-          arguments: ["4\n", "5\n", "6\n", "7\n", "8\n", "9\n", "10\n", "3\n", "45\n", "",-                      "four\n"])+          arguments: ["4\n", "5\n", "6\n", "7\n", "8\n", "9\n", "10\n", "11\n", "3\n", "45\n",+                      "", "four\n"])     func appRejectsEveryOtherMarkerVersion(content: String) async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)@@ -141,7 +142,7 @@ struct MarkerContractTests {     /// The refusal names the digit, so the one library this can happen to says     /// which generation it is on rather than only that it is wrong.     @Test("The refusal names the marker generation it found",-          arguments: ["4", "5", "6", "7", "8", "9", "10"])+          arguments: ["4", "5", "6", "7", "8", "9", "10", "11"])     func appRefusalNamesTheRetiredGeneration(digit: String) async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)@@ -158,11 +159,11 @@ struct MarkerContractTests {      // MARK: - Extension side requires the current version -    @Test("The extension opens a library marked \"12\"")+    @Test("The extension opens a library marked \"13\"")     func extensionAcceptsTheCurrentVersion() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        #expect(try markerContent(cfg) == "12")+        #expect(try markerContent(cfg) == "13")          let (result, _) = try await LibraryRepository.openForExtension(cfg)         #expect(result == .ready(.seededEmpty))@@ -180,26 +181,26 @@ struct MarkerContractTests {      /// Req 8.7 of `configurable-work-types`, now with the live generation:     /// between the app being updated and first launched the library still-    /// records `"11"`, and a capture in that window must fail safely rather than+    /// records `"12"`, and a capture in that window must fail safely rather than     /// convert the store under a shared lock. The message is the actionable one,-    /// because opening the app is what resolves it (Req 11.3).+    /// because opening the app is what resolves it.     @Test("The extension declines the update window and says to open the app")     func extensionDeclinesTheUpdateWindow() async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)-        try writeMarker(cfg, "11\n")+        try writeMarker(cfg, "12\n")          await #expect(throws: Self.openTheApp) {             try await LibraryRepository.openForExtension(cfg)         }-        #expect(try markerContent(cfg) == "11", "the extension may not republish readiness")+        #expect(try markerContent(cfg) == "12", "the extension may not republish readiness")     }      /// The other half of the fork: a generation the app does not open either     /// keeps the "has not initialized" wording, because opening the app would     /// not resolve it.     @Test("The extension declines a retired generation with the unknown-digit message",-          arguments: ["5", "6", "7", "8", "9", "10"])+          arguments: ["5", "6", "7", "8", "9", "10", "11"])     func extensionDeclinesARetiredGeneration(retired: String) async throws {         let (_, cfg) = try config()         try await makeReadyLibrary(cfg)@@ -214,26 +215,26 @@ struct MarkerContractTests {     @Test("The extension declines a \"4\" marker before it constructs a ModelContainer")     func extensionDeclinesBeforeOpeningAContainer() async throws {         let (_, cfg) = try config()-        // A genuinely 11.0.0-recorded store, not a corrupt one: the container-        // *would* open it, converting it to 12.0.0 in a process holding only a+        // A genuinely 12.0.0-recorded store, not a corrupt one: the container+        // *would* open it, converting it to 13.0.0 in a process holding only a         // shared lock (Q14). A store that cannot be opened at all would prove         // nothing about the ordering, which is why this uses the         // frozen-snapshot seed.-        try V11RecordedStoreFixture.install(at: cfg.storeURL)+        try V12RecordedStoreFixture.install(at: cfg.storeURL)         try writeMarker(cfg, "4\n")          await #expect(throws: Self.declined) {             try await LibraryRepository.openForExtension(cfg)         }-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["11.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["12.0.0"],                 "the marker check must decide before ModelContainer.init converts anything") -        // Control: with a "12" marker the same store is reached, opened, and+        // Control: with a "13" 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, "12\n")+        try writeMarker(cfg, "13\n")         _ = try await LibraryRepository.openForExtension(cfg)-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["12.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: cfg.storeURL) == ["13.0.0"],                 "the same store converts once the marker check passes")     } 
Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swift Renamed +69 / -70
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTwelveTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swiftsimilarity index 75%rename from Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTwelveTests.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swiftindex 679c669..0c33a06 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTwelveTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationThirteenTests.swift@@ -4,41 +4,41 @@ import Testing  @testable import AsterismCore -/// The `"11"` → `"12"` generation, end to end (Req 11.1, 11.2, 11.3, 11.4).+/// The `"12"` → `"13"` generation, end to end (Req 5.2, 5.6). ///-/// V12's arm has the same shape as V11's: the lightweight stage does the whole+/// V13's arm has the same shape as V12's: the lightweight stage does the whole /// of the conversion inside `ModelContainer.init`, so there is no data pass and /// no reconciler to run after it. What is left is the sequence — open, validate, /// publish — the failure that must leave the marker where it found it, and both /// halves of the extension's fork. ///-/// What *is* new is that V12 **adds only tables**: three of them, `Creator`,-/// `CreatorRole` and `WorkCredit`, and no column on any existing entity, so-/// there is no attribute default to write at all.-/// `V11RecordedStoreTests` is where the addition is asserted table by table;+/// V13 **adds only tables**, as V12 did: two of them, `Place` and+/// `PlaceSuppression`, and no column on any existing entity, so there is no+/// attribute default to write at all.+/// `V12RecordedStoreTests` is where the addition is asserted table by table; /// this suite is about the marker and the order. ///-/// Every case runs over a library a **V11 build** left behind: a store recorded-/// at 11.0.0 with no creator, role or credit table, marked `"11"`.-@Suite("Marker generation 12", .serialized)-struct MarkerGenerationTwelveTests {+/// Every case runs over a library a **V12 build** left behind: a store recorded+/// at 12.0.0 with no place or place-suppression table, marked `"12"`.+@Suite("Marker generation 13", .serialized)+struct MarkerGenerationThirteenTests {      private final class Root {         let url: URL         let configuration: LibraryConfiguration         init() throws {             url = FileManager.default.temporaryDirectory.appending(-                path: "MarkerEleven-\(UUID())", directoryHint: .isDirectory)+                path: "MarkerTwelve-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)             configuration = LibraryConfiguration(rootDirectory: url)         }         deinit { try? FileManager.default.removeItem(at: url) } -        /// What a device that has run the V11 build holds: the store recorded-        /// at 11.0.0, and the marker at `"11"`.-        func seedV11Library() throws {-            try V11RecordedStoreFixture.install(at: configuration.storeURL)-            try writeMarker("11\n")+        /// What a device that has run the V12 build holds: the store recorded+        /// at 12.0.0, and the marker at `"12"`.+        func seedV12Library() throws {+            try V12RecordedStoreFixture.install(at: configuration.storeURL)+            try writeMarker("12\n")         }          func writeMarker(_ content: String) throws {@@ -51,37 +51,37 @@ struct MarkerGenerationTwelveTests {         }     } -    // MARK: - Req 11.2: the classification+    // MARK: - The classification -    @Test("An \"11\" marker over a store classifies as the lagging generation")-    func elevenIsLagging() throws {+    @Test("A \"12\" marker over a store classifies as the lagging generation")+    func twelveIsLagging() throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)-                == .markerLagging(generation: "11"))+                == .markerLagging(generation: "12"))         withExtendedLifetime(root) {}     } -    @Test("A \"12\" marker over a store classifies ready")-    func twelveIsReady() throws {+    @Test("A \"13\" marker over a store classifies ready")+    func thirteenIsReady() throws {         let root = try Root()-        try root.seedV11Library()-        try root.writeMarker("12\n")+        try root.seedV12Library()+        try root.writeMarker("13\n")          #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)         withExtendedLifetime(root) {}     } -    /// `"10"` joins the retired digits: the arm that used to convert it is gone+    /// `"11"` joins the retired digits: the arm that used to convert it is gone     /// from the marker set and the refusal names the digit like any other. The-    /// V10 → V11 *stage* went in the same commit this time rather than one-    /// later (Q15), so the plan can no longer convert such a store either.+    /// V11 → V12 *stage* went in the same commit, as V12's own predecessor did+    /// (Q48), so the plan can no longer convert such a store either.     @Test("Any other generation is unrecognised, and the refusal names it",-          arguments: ["4", "5", "6", "7", "8", "9", "10"])+          arguments: ["4", "5", "6", "7", "8", "9", "10", "11"])     func otherGenerationsAreUnrecognised(digit: String) throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         try root.writeMarker("\(digit)\n")          guard case .unrecognised(let reason) = try LibraryRepository.classify(@@ -93,16 +93,16 @@ struct MarkerGenerationTwelveTests {         withExtendedLifetime(root) {}     } -    // MARK: - Req 11.1: the arm+    // MARK: - The arm      /// The whole sequence: the store converts on the way in, it validates, and-    /// only then does the marker move. Nothing else runs — the stage adds three+    /// only then does the marker move. Nothing else runs — the stage adds two     /// empty tables and the launch reconcile handles anything that arrived     /// since.     @Test("The app arm converts, validates and republishes")     func armRunsTheWholeSequence() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()          let (result, repository) = try await LibraryRepository.openForApp(root.configuration)         defer { withExtendedLifetime(root) {} }@@ -114,9 +114,9 @@ struct MarkerGenerationTwelveTests {         }         #expect(counts.works == 1)         #expect(counts.entries == 3)-        #expect(try root.markerText() == "12")+        #expect(try root.markerText() == "13")         #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)-                == ["12.0.0"], "the store the arm opened is recorded at the version it converted to")+                == ["13.0.0"], "the store the arm opened is recorded at the version it converted to")          let facts = try await repository.withLockedContext(             mode: .shared, operation: "reading the converted library"@@ -131,8 +131,8 @@ struct MarkerGenerationTwelveTests {         }         await repository.shutdown()         #expect(facts.memberships == [-            "\(V11RecordedStoreFixture.hostname)|\(V11RecordedStoreFixture.workIdentity)"-                + "|\(V11RecordedStoreFixture.workID.uuidString)",+            "\(V12RecordedStoreFixture.hostname)|\(V12RecordedStoreFixture.workIdentity)"+                + "|\(V12RecordedStoreFixture.workID.uuidString)",         ])         // The arm converts nothing beyond the stage, so the fixture's one         // nil-blob Entry is still nil-blob on the far side — a report, not a@@ -145,7 +145,7 @@ struct MarkerGenerationTwelveTests {     @Test("The second open is an ordinary ready open")     func secondOpenIsReady() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         let (_, first) = try await LibraryRepository.openForApp(root.configuration)         await first.shutdown() @@ -157,34 +157,33 @@ struct MarkerGenerationTwelveTests {             Issue.record("expected a ready library, got \(result)")             return         }-        #expect(try root.markerText() == "12")+        #expect(try root.markerText() == "13")         withExtendedLifetime(root) {}     } -    /// Req 11.1: **the marker goes last**, so a throw anywhere above it leaves-    /// `"11"` on disk and the next open re-enters the arm over an already-    /// converted store — which is a no-op, because adding tables that are-    /// already there is one.+    /// **The marker goes last**, so a throw anywhere above it leaves `"12"` on+    /// disk and the next open re-enters the arm over an already converted store+    /// — which is a no-op, because adding tables that are already there is one.     ///     /// The arm has exactly two things that can throw — the open and the     /// validation — and both are above `publishReadiness`. This drives the open,     /// because it is the one a test can force: the store is replaced by bytes no     /// coordinator will read, restored, and opened again. What it pins is the     /// ordering, not which of the two failed.-    @Test("A failed open leaves the marker at \"11\", and the next open completes it")+    @Test("A failed open leaves the marker at \"12\", and the next open completes it")     func aFailedOpenLeavesTheMarkerAlone() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         let intact = try Data(contentsOf: root.configuration.storeURL)         try Data("not a database".utf8).write(to: root.configuration.storeURL, options: .atomic)          await #expect(throws: (any Error).self) {             try await LibraryRepository.openForApp(root.configuration)         }-        #expect(try root.markerText() == "11",+        #expect(try root.markerText() == "12",                 "the marker may not move over an open that did not complete")         #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)-                == .markerLagging(generation: "11"),+                == .markerLagging(generation: "12"),                 "the next open re-enters the same arm")          try intact.write(to: root.configuration.storeURL, options: .atomic)@@ -194,19 +193,19 @@ struct MarkerGenerationTwelveTests {             Issue.record("expected the retry to reach a ready library, got \(result)")             return         }-        #expect(try root.markerText() == "12")+        #expect(try root.markerText() == "13")         withExtendedLifetime(root) {}     }      /// Validation opens with diagnoses rather than refusing, exactly as the-    /// `.ready` arm does — a library that opened on V11 opens on V12,+    /// `.ready` arm does — a library that opened on V12 opens on V13,     /// quarantines and all. Only a validation that *throws* stops the marker.     @Test("A library with a quarantined hostname still opens, and reports it")     func validationDiagnosesRatherThanRefuses() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         // Break the cited chapter pattern's definition, which is an-        // `.unreadableTitlePattern` quarantine on V11 and must stay one on V12.+        // `.unreadableTitlePattern` quarantine on V12 and must stay one on V13.         do {             let container = try LibraryRepository.openContainer(at: root.configuration.storeURL)             let context = ModelContext(container)@@ -224,21 +223,21 @@ struct MarkerGenerationTwelveTests {          let (result, repository) = try await LibraryRepository.openForApp(root.configuration)         let quarantined = await repository.quarantineReason(-            hostname: V11RecordedStoreFixture.hostname)+            hostname: V12RecordedStoreFixture.hostname)         await repository.shutdown()          guard case .ready = result else {             Issue.record("a diagnosable library must still open, got \(result)")             return         }-        #expect(try root.markerText() == "12")-        #expect(quarantined != nil, "the broken title rule quarantines its hostname, as on V11")+        #expect(try root.markerText() == "13")+        #expect(quarantined != nil, "the broken title rule quarantines its hostname, as on V12")         withExtendedLifetime(root) {}     } -    /// Q36's other half: **the cleanup goes after the publish**, so a marker-    /// write that fails leaves the historical marker and the migration sidecar-    /// where the next open expects to find them.+    /// **The cleanup goes after the publish**, so a marker write that fails+    /// leaves the historical marker and the migration sidecar where the next+    /// open expects to find them.     ///     /// The publish is forced to fail by taking write permission off the     /// directory the readiness marker sits in. The **sidecar** is the@@ -248,7 +247,7 @@ struct MarkerGenerationTwelveTests {     @Test("A failed publish leaves the historical marker and the sidecar in place")     func aFailedPublishKeepsTheResidualEvidence() throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         try Data("3\n".utf8).write(             to: root.configuration.historicalMarkerURL, options: .atomic)         try Data("stale\n".utf8).write(@@ -264,9 +263,9 @@ struct MarkerGenerationTwelveTests {          #expect(throws: (any Error).self) {             try LibraryRepository.act(-                on: .markerLagging(generation: "11"), root.configuration, hooks: .production)+                on: .markerLagging(generation: "12"), root.configuration, hooks: .production)         }-        #expect(try root.markerText() == "11",+        #expect(try root.markerText() == "12",                 "the marker may not move over a publish that did not complete")         #expect(files.fileExists(atPath: root.configuration.historicalMarkerURL.path))         #expect(files.fileExists(atPath: root.configuration.migrationSidecarURL.path),@@ -274,29 +273,29 @@ struct MarkerGenerationTwelveTests {         withExtendedLifetime(root) {}     } -    // MARK: - Req 11.3: the extension's fork+    // MARK: - The extension's fork -    @Test("The extension refuses \"11\" and says to open the app")+    @Test("The extension refuses \"12\" and says to open the app")     func extensionRefusesTheLaggingGeneration() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()          await #expect(throws: LibraryRepositoryError.libraryUnavailable(             operation: "opening library from extension",             reason: "Open Asterism to finish updating the library")) {             try await LibraryRepository.openForExtension(root.configuration)         }-        #expect(try root.markerText() == "11", "the extension may not convert or republish")+        #expect(try root.markerText() == "12", "the extension may not convert or republish")         #expect(try V4RecordedStoreFixture.recordedModelVersions(at: root.configuration.storeURL)-                == ["11.0.0"], "and may not let ModelContainer.init convert the store")+                == ["12.0.0"], "and may not let ModelContainer.init convert the store")         withExtendedLifetime(root) {}     }      @Test("The extension keeps the existing reason for a generation no build opens",-          arguments: ["4", "5", "6", "7", "8", "9", "10"])+          arguments: ["4", "5", "6", "7", "8", "9", "10", "11"])     func extensionRefusesUnknownDigits(digit: String) async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         try root.writeMarker("\(digit)\n")          await #expect(throws: LibraryRepositoryError.libraryUnavailable(@@ -307,13 +306,13 @@ struct MarkerGenerationTwelveTests {         withExtendedLifetime(root) {}     } -    @Test("The extension opens \"12\"")+    @Test("The extension opens \"13\"")     func extensionOpensTheCurrentGeneration() async throws {         let root = try Root()-        try root.seedV11Library()+        try root.seedV12Library()         let (_, repository) = try await LibraryRepository.openForApp(root.configuration)         await repository.shutdown()-        #expect(try root.markerText() == "12")+        #expect(try root.markerText() == "13")          let (result, _) = try await LibraryRepository.openForExtension(root.configuration)         guard case .ready = result else {
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swiftindex 5996a63..3b8f427 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipReconcilerTests.swift@@ -1052,12 +1052,12 @@ private final class ReconcileStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismMembershipReconciler-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift Modified +5 / -5
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swiftindex 12eb3a8..eebb57c 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipTestSupport.swift@@ -109,7 +109,7 @@ extension LibraryRepository {         }     } -    /// Exports and decode-validates, which is exactly what `BackupV11Exporter`+    /// Exports and decode-validates, which is exactly what `BackupV12Exporter`     /// does — and `BackupArchiveReferenceChecks` is where a membership whose     /// hostname and cited identity rule describe **different sites** is refused,     /// along with the rest of the identity tuple. Three separate membership bugs@@ -119,11 +119,11 @@ extension LibraryRepository {         sourceLocation: SourceLocation = #_sourceLocation     ) async throws {         do {-            let payload = try await backupV11Snapshot()-            let encoded = try BackupV11Codec.encode(+            let payload = try await backupV12Snapshot()+            let encoded = try BackupV12Codec.encode(                 payload: payload,-                metadata: BackupV11Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))-            _ = try BackupV11Codec.decode(encoded)+                metadata: BackupV12Metadata(appBuild: "test", exportedAt: M5Fixture.epoch))+            _ = try BackupV12Codec.decode(encoded)         } catch {             Issue.record(                 comment ?? "the archive is not legal: \(error)", sourceLocation: sourceLocation)
Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swiftindex 1c07051..ac11307 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MembershipValidationTests.swift@@ -345,12 +345,12 @@ private final class MembershipStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismMembershipValidation-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift Modified +3 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swiftindex 7dace85..838485e 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MirroringBootstrapLifecycleTests.swift@@ -126,7 +126,7 @@ struct MirroringBootstrapLifecycleTests {         // container is constructed, the store is marked at the current         // generation — so CloudKit cannot fill an unmarked store         // (Req 6.1, Q22, Q35).-        #expect(call.markerVersion == "12")+        #expect(call.markerVersion == "13")         #expect(call.storeExists)         #expect(call.containerID == Self.fixtureContainer)         #expect(call.storeURL == configuration.storeURL)@@ -149,7 +149,7 @@ struct MirroringBootstrapLifecycleTests {             mirroring: recordingHooks(configuration, log: log, bootstrapBox: bootstrapBox))          #expect(log.callCount == 1)-        #expect(log.calls.first?.markerVersion == "12")+        #expect(log.calls.first?.markerVersion == "13")         #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,@@ -176,7 +176,7 @@ struct MirroringBootstrapLifecycleTests {          #expect(result == .ready(LibraryRecordCounts(             entries: 0, works: 0, sites: 1, titlePatterns: 0).withSeededWorkTypes))-        #expect(log.calls.first?.markerVersion == "12")+        #expect(log.calls.first?.markerVersion == "13")         #expect(await repository.mirroring.isMirroring)         withExtendedLifetime(dir) {}     }
Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift Modified +108 / -57
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swiftindex 8cde4de..1d50fc8 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift@@ -153,41 +153,39 @@ struct ModelContractTests {         #expect(entry.intentionallyUnattached == false)     } -    /// The entity list is the store's shape. V11 declares twelve entities and-    /// **V12 declares those twelve plus `Creator`, `CreatorRole` and-    /// `WorkCredit`** — the first stage in the project's history that adds-    /// *only* tables. The frozen snapshot is the `from` side of the one-    /// lightweight stage, so a divergence here is a store that will not open,-    /// not a test that needs updating.-    @Test("V12 declares V11's twelve entities plus Creator, CreatorRole and WorkCredit")+    /// The entity list is the store's shape. V12 declares fifteen entities and+    /// **V13 declares those fifteen plus `Place` and `PlaceSuppression`** — the+    /// second stage in the project's history that adds *only* tables. The frozen+    /// snapshot is the `from` side of the one lightweight stage, so a divergence+    /// here is a store that will not open, not a test that needs updating.+    @Test("V13 declares V12's fifteen entities plus Place and PlaceSuppression")     func schemaEntityLists() {-        let twelve = [+        let fifteen = [             "Entry", "Work", "Site", "TitlePattern", "URLRulePattern", "WorkTypeEntity",             "Character", "CharacterSuppression", "WorkSiteMembership", "WorkDistinctPair",-            "Series", "WorkLink",+            "Series", "WorkLink", "Creator", "CreatorRole", "WorkCredit",         ]+        #expect(AsterismSchemaV13.versionIdentifier == Schema.Version(13, 0, 0))+        #expect(AsterismSchemaV13.models.map { String(describing: $0) }+                == fifteen + ["Place", "PlaceSuppression"])         #expect(AsterismSchemaV12.versionIdentifier == Schema.Version(12, 0, 0))-        #expect(AsterismSchemaV12.models.map { String(describing: $0) }-                == twelve + ["Creator", "CreatorRole", "WorkCredit"])-        #expect(AsterismSchemaV11.versionIdentifier == Schema.Version(11, 0, 0))-        #expect(AsterismSchemaV11.models.map { String(describing: $0) } == twelve)+        #expect(AsterismSchemaV12.models.map { String(describing: $0) } == fifteen)     } -    /// **V12 adds no `Work` column at all**, which is what makes this bump the-    /// first that adds only tables — and is therefore worth asserting rather+    /// **V13 adds no `Work` column at all**, which is what makes this bump the+    /// second that adds only tables — and is therefore worth asserting rather     /// than assuming: the two schemas' `Work` entities must have the *same*-    /// property set, and it must still carry every column the previous two-    /// stages supplied.+    /// property set, and it must still carry every column the earlier stages+    /// supplied.     ///-    /// The "and absent from the previous snapshot" half of the V10 and V11-    /// column pins went with `AsterismSchemaV10` (Q15): there is no frozen-    /// snapshot below V11 left to compare against, and the V10 → V11 stage they-    /// described is retired. What is still assertable — and still worth-    /// asserting, because V11 is the `from` side every installed library is-    /// matched on — is that all five columns are in the snapshot's own schema+    /// The "and absent from the previous snapshot" half of the older column pins+    /// went with the snapshots that carried them: there is no frozen schema+    /// below V12 left to compare against. What is still assertable — and still+    /// worth asserting, because V12 is the `from` side every installed library+    /// is matched on — is that all five columns are in the snapshot's own schema     /// and that the stage adds none beside them.-    @Test("V12 adds no Work column, and V11's Work carries the five it inherited")-    func workColumnsAreUnchangedAtV12() {+    @Test("V13 adds no Work column, and V12's Work carries the five it inherited")+    func workColumnsAreUnchangedAtV13() {         func workProperties(_ schema: Schema) -> Set<String> {             guard let work = schema.entities.first(where: { $0.name == "Work" }) else { return [] }             return Set(work.properties.map(\.name)).union(work.relationships.map(\.name))@@ -196,16 +194,16 @@ struct ModelContractTests {             "workStatusRaw", "readingStatusRaw", "verdict",  // V10             "seriesID", "seriesPosition",                    // V11         ]-        let live = workProperties(Schema(versionedSchema: AsterismSchemaV12.self))-        let frozen = workProperties(Schema(versionedSchema: AsterismSchemaV11.self))+        let live = workProperties(Schema(versionedSchema: AsterismSchemaV13.self))+        let frozen = workProperties(Schema(versionedSchema: AsterismSchemaV12.self))         for column in inherited {-            #expect(frozen.contains(column), "Work.\(column) is missing from the V11 schema")-            #expect(live.contains(column), "Work.\(column) is missing from the V12 schema")+            #expect(frozen.contains(column), "Work.\(column) is missing from the V12 schema")+            #expect(live.contains(column), "Work.\(column) is missing from the V13 schema")         }         #expect(             live == frozen,             """-            V11 -> V12 is meant to add only tables, but the two Work entities \+            V12 -> V13 is meant to add only tables, but the two Work entities \             differ: added \(live.subtracting(frozen).sorted()), \             removed \(frozen.subtracting(live).sorted())             """)@@ -213,19 +211,72 @@ struct ModelContractTests {         #expect(frozen.contains("titleProvenanceRaw"))     } -    /// V12's three entities, as the *schema* records them: present in V12 and-    /// absent from the frozen V11 snapshot the stage converts from.-    @Test("The three credit tables are in V12 and not in the frozen V11")-    func creditTablesAreV12Additions() {-        let live = Set(Schema(versionedSchema: AsterismSchemaV12.self).entities.map(\.name))-        let frozen = Set(Schema(versionedSchema: AsterismSchemaV11.self).entities.map(\.name))-        for table in ["Creator", "CreatorRole", "WorkCredit"] {-            #expect(live.contains(table), "\(table) is missing from the V12 schema")-            #expect(!frozen.contains(table), "\(table) is in the frozen V11 snapshot")+    /// V13's two entities, as the *schema* records them: present in V13 and+    /// absent from the frozen V12 snapshot the stage converts from.+    @Test("The two place tables are in V13 and not in the frozen V12")+    func placeTablesAreV13Additions() {+        let live = Set(Schema(versionedSchema: AsterismSchemaV13.self).entities.map(\.name))+        let frozen = Set(Schema(versionedSchema: AsterismSchemaV12.self).entities.map(\.name))+        for table in ["Place", "PlaceSuppression"] {+            #expect(live.contains(table), "\(table) is missing from the V13 schema")+            #expect(!frozen.contains(table), "\(table) is in the frozen V12 snapshot")+        }+        // The control: the frozen snapshot is a real schema, and it is V12's —+        // it carries the three tables *that* stage added.+        #expect(frozen.isSuperset(of: ["Creator", "CreatorRole", "WorkCredit"]))+    }++    /// V13's two entities, as CloudKit will materialise them: every property+    /// defaulted or optional, nothing unique, **no relationship on either**, and+    /// the owning work a plain `UUID` column so a place survives the absence of+    /// its work (Q44, Decision 2). The two enum columns default to the raw+    /// **literals** rather than to a case's `rawValue`, so the next freeze+    /// inherits no new frozen enum spelling.+    @Test("Place and PlaceSuppression defaults are CloudKit-legal")+    func placeDefaults() {+        let epoch = Date(timeIntervalSince1970: 0)++        let place = Place()+        #expect(place.name.isEmpty)+        #expect(place.nameKey.isEmpty)+        #expect(place.aliases.isEmpty)+        #expect(place.note.isEmpty)+        // Nil rather than defaulted-empty, exactly as `Character.factsData` is:+        // CloudKit materialises a missing column as nil, and "no facts yet" and+        // "column not synced" are the same thing to a reader.+        #expect(place.factsData == nil)+        #expect(place.facts.isEmpty)+        #expect(place.createdAt == epoch)+        #expect(place.modifiedAt == epoch)++        let suppression = PlaceSuppression()+        #expect(suppression.kindRaw == "candidate")+        #expect(suppression.statusRaw == "active")+        // The literals the columns default to are the shared enums', tied+        // together here so a rename of either side fails this contract rather+        // than a convergence rule.+        #expect(suppression.kindRaw == CharacterSuppressionKind.candidate.rawValue)+        #expect(suppression.statusRaw == CharacterSuppressionStatus.active.rawValue)+        #expect(suppression.nameKey.isEmpty)+        #expect(suppression.sourceKindRaw == nil)+        #expect(suppression.sourceEntryID == nil)+        #expect(suppression.evidence == nil)+        #expect(suppression.actionAt == epoch)++        // The schema's own view: nothing unique, no relationship on either new+        // table, and the owner a column rather than a reference.+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)+        for name in ["Place", "PlaceSuppression"] {+            guard let entity = schema.entities.first(where: { $0.name == name }) else {+                Issue.record("the V13 schema has no \(name) entity")+                continue+            }+            #expect(entity.relationships.isEmpty, "\(name) declares a relationship")+            #expect(entity.uniquenessConstraints.isEmpty, "\(name) declares a uniqueness constraint")+            #expect(+                Set(entity.properties.map(\.name)).contains("workID"),+                "\(name) does not name its work by column")         }-        // The control: the frozen snapshot is a real schema, and it is V11's —-        // it carries the two tables *that* stage added.-        #expect(frozen.isSuperset(of: ["Series", "WorkLink"]))     }      /// V12's three entities, as CloudKit will materialise them: every property@@ -291,7 +342,7 @@ struct ModelContractTests {          // The schema's own view: nothing unique, no relationships on any of the         // three, and every foreign reference a column rather than a reference.-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         for name in ["Creator", "CreatorRole", "WorkCredit"] {             guard let entity = schema.entities.first(where: { $0.name == name }) else {                 Issue.record("the V12 schema has no \(name) entity")@@ -332,7 +383,7 @@ struct ModelContractTests {          // The schema's own view: nothing unique, no relationships on either new         // table, and the two link ends are UUID columns rather than references.-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         for name in ["Series", "WorkLink"] {             guard let entity = schema.entities.first(where: { $0.name == name }) else {                 Issue.record("the V12 schema has no \(name) entity")@@ -465,7 +516,7 @@ struct ModelContractTests {     /// V8-era half of this pin needed an allowlist and this one does not.     @Test("No dropped column is in the V12 schema")     func droppedColumnsAreGoneFromTheSchema() {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         var propertiesByEntity: [String: Set<String>] = [:]         for entity in schema.entities {             propertiesByEntity[entity.name, default: []]@@ -486,17 +537,17 @@ struct ModelContractTests {         #expect(propertiesByEntity["Work"]?.contains("siteMemberships") == true)     } -    /// **Every frozen snapshot is a stage's `from` side.** `AsterismSchemaV11`-    /// is the one the plan names; V5, V6, V7, V8, V9 and V10 went with the-    /// stages that named them (Q2 of `drop-superseded-columns`, Q18 of-    /// `work-and-reading-status`, Q60 of `series-and-related-works`, Q15 here),-    /// and a file that starts declaring a snapshot without a stage to be the-    /// `from` side of is a store shape nothing can reach.+    /// **Every frozen snapshot is a stage's `from` side.** `AsterismSchemaV12`+    /// is the one the plan names; V5 through V11 went with the stages that named+    /// them (Q2 of `drop-superseded-columns`, Q18 of `work-and-reading-status`,+    /// Q60 of `series-and-related-works`, Q15 of `work-creators`, Q48 here), and+    /// a file that starts declaring a snapshot without a stage to be the `from`+    /// side of is a store shape nothing can reach.     ///-    /// There are two declarations rather than one because `AsterismSchemaV12` is-    /// the live schema, and the count of *snapshots* is one: V10 went in this+    /// There are two declarations rather than one because `AsterismSchemaV13` is+    /// the live schema, and the count of *snapshots* is one: V11 went in this     /// bump's freeze commit, the owner having confirmed every device on marker-    /// `"11"` before it ran.+    /// `"12"` before it ran.     ///     /// The columns V9 dropped are now named **nowhere** in the package's     /// sources: the frozen V8 snapshot that was their last home went with the@@ -543,11 +594,11 @@ struct ModelContractTests {          #expect(             declaringSnapshots.sorted() == [-                "AsterismSchemaV11.swift", "AsterismSchemaV12.swift",+                "AsterismSchemaV12.swift", "AsterismSchemaV13.swift",             ],             """             the package declares versioned schemas in \(declaringSnapshots.sorted()); \-            the plan is [V11, V12] and every snapshot must be a stage's `from` side+            the plan is [V12, V13] and every snapshot must be a stage's `from` side             """)         #expect(             naming.isEmpty,@@ -771,7 +822,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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             schema: schema,             isStoredInMemoryOnly: true,
Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift Modified +4 / -4
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swiftindex 1e913f1..ceaef53 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReadPathTests.swift@@ -121,10 +121,10 @@ struct MultiSiteReadPathTests {             // The model itself: `siteMemberships` is declared here and             // `membershipValues` / `membership(for:)` are its accessors.             "Models.swift",-            // The frozen V11 snapshot **declares** the inverse array; it reads+            // The frozen V12 snapshot **declares** the inverse array; it reads             // nothing, and nothing reads it — a snapshot carries stored columns             // and no accessors at all.-            "AsterismSchemaV11.swift",+            "AsterismSchemaV12.swift",             // The reconcilers and the scan, which are *about* the membership             // graph and read it whole per pass rather than per row.             "DuplicateReconciler.swift", "DuplicateScan.swift", "MembershipReconciler.swift",@@ -201,12 +201,12 @@ private final class MembershipStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismMultiSiteRead-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift Modified +7 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swiftindex 9d7adaa..cf3ea73 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/MultiSiteReviewFixTests.swift@@ -259,7 +259,7 @@ struct MultiSiteReviewFixTests {     @Test("An Entry citing another site's rule is refused before a file exists")     func exportGateRefusesACrossSiteEntryCitation() throws {         let ruleID = UUID()-        let rule = BackupV11URLRule(+        let rule = BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch,             origin: .readerTaught,             definition: .work(locator: .query(name: ExactScalarString("identity"))),@@ -268,12 +268,12 @@ struct MultiSiteReviewFixTests {             hostname: "a.example",             citations: EntryCitations(chapterSequence: CitedRule(id: ruleID))) -        #expect(throws: BackupV11ExportError.self) {+        #expect(throws: BackupV12ExportError.self) {             try LibraryRepository.requireCitationsResolve(                 entries: [entry], memberships: [], titlePatterns: [], urlRules: [rule])         }         // Same rule, taught for the Entry's own site: legal.-        let sameSite = BackupV11URLRule(+        let sameSite = BackupV12URLRule(             id: ruleID, version: 1, isCurrent: true, createdAt: Self.epoch,             origin: .readerTaught,             definition: .work(locator: .query(name: ExactScalarString("identity"))),@@ -285,7 +285,7 @@ struct MultiSiteReviewFixTests {     @Test("A chapter rule taught for another site is refused too")     func exportGateRefusesACrossSitePatternCitation() throws {         let patternID = UUID()-        let pattern = BackupV11TitlePattern(+        let pattern = BackupV12TitlePattern(             id: patternID, siteHostname: "b.example", version: 1, isActive: true,             createdAt: Self.epoch,             definition: StoredPatternDefinition(definition: .wholeTitle))@@ -295,7 +295,7 @@ struct MultiSiteReviewFixTests {                 chapterTitle: try FieldProvenance(                     kind: .pattern, patternID: patternID))) -        #expect(throws: BackupV11ExportError.self) {+        #expect(throws: BackupV12ExportError.self) {             try LibraryRepository.requireCitationsResolve(                 entries: [entry], memberships: [], titlePatterns: [pattern], urlRules: [])         }@@ -303,9 +303,9 @@ struct MultiSiteReviewFixTests {      private static func wireEntry(         hostname: String, citations: EntryCitations-    ) -> BackupV11Entry {+    ) -> BackupV12Entry {         let url = "https://\(hostname)/one"-        return BackupV11Entry(+        return BackupV12Entry(             id: UUID(), captureTitle: "Chapter", captureTitleSource: .host, rawURL: url,             canonicalURL: nil, hostname: hostname, entryIdentityKey: url,             conservativeIdentityKey: url, identityBasis: .conservative, urlWorkIdentity: nil,
Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swift Added +505 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swiftnew file mode 100644index 0000000..f8e6707--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/PlaceDuplicateMachineryTests.swift@@ -0,0 +1,505 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Req 3.5, 3.6, 5.3 and 5.5: places in the duplicate, collapse, deletion and+// merge machinery.+//+// The parity claim of the design's convergence table, asserted rather than+// asserted-by-reading: every `.character` site has a `.place` twin that answers+// the same way. The negative claim carries over unchanged — distinct-UUID places+// never form a set, so `.merge` is structurally unreachable for them too — and+// the new claims are the ones a relationship-free table brings with it: an+// orphan is tolerated for ever, and a work's places are reached by predicate.++@Suite("Place duplicate sets (Req 5.3, 5.5)", .serialized)+struct PlaceDuplicateSetTests {++    private static let workID = UUID(uuidString: "0F100000-0000-4000-8000-000000000001")!+    private static let absentWork = UUID(uuidString: "0F100000-0000-4000-8000-0000000000FF")!+    private static let harbour = UUID(uuidString: "0F100000-0000-4000-8000-00000000000A")!+    private static let lighthouse = UUID(uuidString: "0F100000-0000-4000-8000-00000000000B")!++    private func seeded(+        places: [M5SeedPlace], placeSuppressions: [M5SeedPlaceSuppression] = []+    ) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "p.example")],+            works: [M5SeedWork(+                id: Self.workID, displayTitle: "A Serial", hostname: "p.example")],+            places: places,+            placeSuppressions: placeSuppressions)+        return fixture+    }++    /// Q76 over the second table: two places alike in every way are still two+    /// records, because their UUIDs differ.+    @Test("Two distinct-UUID places never form a set, however alike they are")+    func distinctUUIDsNeverFormASet() async throws {+        let fixture = try await seeded(places: [+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workID),+            M5SeedPlace(id: Self.lighthouse, name: "Harbour", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.placeSets.isEmpty,+                "content bucketing would auto-collapse these, which Req 5.3 forbids")+        withExtendedLifetime(fixture) {}+    }++    @Test("Every place set has one member, so a collapse has no loser to delete")+    func setsAlwaysHaveOneMember() async throws {+        let fixture = try await seeded(places: [+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "one", workID: Self.workID),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "two", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.placeSets.count == 1)+        let set = try #require(scan.placeSets.first)+        #expect(set.members.count == 1)+        #expect(set.key.recordType == .place)+        #expect(set.key.memberIDs == [Self.harbour])+        #expect(set.members.first?.rowCount == 2)+        withExtendedLifetime(fixture) {}+    }++    @Test("Same-UUID place rows agreeing about everything authored are silently resolvable")+    func agreeingRowsResolveSilently() async throws {+        let fixture = try await seeded(places: [+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        let set = try #require(scan.placeSets.first)+        #expect(set.classification == .silentlyResolvable)+        #expect(!set.isTorn)+        withExtendedLifetime(fixture) {}+    }++    @Test("A torn place group is divergent and routes to the sheet")+    func disagreeingRowsTearAndRouteToTheSheet() async throws {+        let fixture = try await seeded(places: [+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "quay", workID: Self.workID),+        ])++        let scan = try await fixture.repository.m5Scan()+        let set = try #require(scan.placeSets.first)+        #expect(set.classification == .divergent)+        #expect(set.isTorn)++        let workload = DuplicateWorkload(scan: scan)+        #expect(workload.reviewItems.map(\.key) == [set.key])+        #expect(workload.reviewItems.first?.route == .sheet,+                "a place set has no Merge affordance — there is nothing to merge into")+        #expect(workload.reviewItems.first?.isTorn == true)+        withExtendedLifetime(fixture) {}+    }++    /// Req 5.5: a `workID` naming no `Work` is a tolerated orphan — displayed+    /// nowhere, in no set, and never swept.+    @Test("An orphaned place forms no set and survives a reconcile pass")+    func orphanIsToleratedAndNeverSwept() async throws {+        let fixture = try await seeded(+            places: [M5SeedPlace(id: Self.harbour, name: "Nowhere", workID: Self.absentWork)],+            placeSuppressions: [+                M5SeedPlaceSuppression(workID: Self.absentWork, nameKey: "ghost"),+            ])++        let scan = try await fixture.repository.m5Scan()+        #expect(scan.placeSets.isEmpty)++        _ = try await fixture.repository.reconcileAfterSync()+        #expect(try await fixture.repository.m5AllPlaces().map(\.id) == [Self.harbour],+                "orphans go with their parent, and this one has none (CLAUDE.md)")+        #expect(try await fixture.repository.m5PlaceSuppressionRows().count == 1)+        withExtendedLifetime(fixture) {}+    }++    /// A place whose work a **pre-feature build deleted** is the same permanent+    /// orphan reached by the other route: the work is gone, so nothing can ever+    /// resolve the column, and no pass removes it.+    @Test("A place whose work a pre-feature build deleted is a permanent orphan")+    func preFeatureDeletedWorkLeavesAPermanentOrphan() async throws {+        let fixture = try await seeded(places: [+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workID),+        ])+        // Deleted the way a build that cannot see `Place` deletes a work: the+        // rows go, the place stays, which is exactly the shape Req 5.5 tolerates.+        try await fixture.repository.m5DeleteWorkRowsOnly(workID: Self.workID)++        _ = try await fixture.repository.reconcileAfterSync()+        let places = try await fixture.repository.m5AllPlaces()+        #expect(places.map(\.id) == [Self.harbour])+        #expect(places.first?.workID == Self.workID, "it keeps the id it would re-attach by")+        withExtendedLifetime(fixture) {}+    }++    /// The case order feeds `DuplicateSetKey`'s sort, so appending is not a+    /// stylistic choice.+    @Test("`.place` is the last DuplicateRecordType case")+    func placeIsAppendedLast() {+        #expect(DuplicateRecordType.allCases.last == .place)+        #expect(DuplicateRecordType.character < DuplicateRecordType.place)+    }+}++@Suite("Place group convergence and torn resolution (Req 5.3)", .serialized)+struct PlaceConvergenceTests {++    private static let workID = UUID(uuidString: "0F200000-0000-4000-8000-000000000001")!+    private static let harbour = UUID(uuidString: "0F200000-0000-4000-8000-00000000000A")!++    private func seeded(_ places: [M5SeedPlace]) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "p.example")],+            works: [M5SeedWork(+                id: Self.workID, displayTitle: "A Serial", hostname: "p.example")],+            places: places)+        return fixture+    }++    @Test("A converging pass normalises every row of an agreeing place group")+    func convergenceNormalisesEveryRow() async throws {+        let fixture = try await seeded([+            M5SeedPlace(+                id: Self.harbour, name: "Harbour", aliases: ["The Docks", "Quay"],+                workID: Self.workID),+            M5SeedPlace(+                id: Self.harbour, name: "Harbour", aliases: ["Quay", "The Docks"],+                workID: Self.workID),+        ])++        _ = try await fixture.repository.reconcileAfterSync()+        let rows = try await fixture.repository.m5PlaceRows(id: Self.harbour)+        #expect(rows.count == 2)+        #expect(Set(rows.map { $0.aliases }).count == 1, "the rows converged on one alias list")+        withExtendedLifetime(fixture) {}+    }++    /// Nothing on the silent path reads a clock (Q56), so convergence writes the+    /// content and leaves every row's stamp where it found it — otherwise+    /// convergence would itself be an edit and the fixed point unreachable.+    @Test("Convergence writes the content and no timestamp")+    func convergenceWritesNoTimestamp() async throws {+        let stamped = M5Fixture.epoch.addingTimeInterval(-7_200)+        let fixture = try await seeded([+            M5SeedPlace(+                id: Self.harbour, name: "Harbour", aliases: ["The Docks", "Quay"],+                workID: Self.workID, modifiedAt: stamped),+            M5SeedPlace(+                id: Self.harbour, name: "Harbour", aliases: ["Quay", "The Docks"],+                workID: Self.workID, modifiedAt: stamped),+        ])++        let outcome = try await fixture.repository.reconcileAfterSync().duplicates+        #expect(outcome.contentWrites > 0)+        let places = try await fixture.repository.m5AllPlaces()+        #expect(places.allSatisfy { $0.aliases == ["Quay", "The Docks"] })+        #expect(places.allSatisfy { $0.modifiedAt == stamped },+                "a stamp here would make convergence an edit two devices could not agree on")+        withExtendedLifetime(fixture) {}+    }++    @Test("A converging pass leaves a torn place group torn")+    func convergenceLeavesTornGroupsAlone() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "quay", workID: Self.workID),+        ])++        let outcome = try await fixture.repository.reconcileAfterSync().duplicates+        #expect(outcome.reviewSetKeys.contains {+            $0.recordType == .place && $0.memberIDs == [Self.harbour]+        }, "a torn place group is published as the reader's work")++        let rows = try await fixture.repository.m5PlaceRows(id: Self.harbour)+        #expect(Set(rows.map(\.note)) == ["docks", "quay"], "no variant was chosen for them")+        withExtendedLifetime(fixture) {}+    }++    /// The `.character` arm's write shape: chosen-only, no union, nothing+    /// deleted.+    @Test("Resolving a torn place group writes the chosen variant to every row")+    func resolutionWritesChosenOnly() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+            M5SeedPlace(+                id: Self.harbour, name: "Harbour", aliases: ["The Quay"], note: "quay",+                workID: Self.workID),+        ])+        let scan = try await fixture.repository.m5Scan()+        let key = try #require(scan.placeSets.first?.key)++        let contract = try await fixture.repository.projectDuplicateResolution(setKey: key)+        guard case .place(_, let variants, let fields, let preselected) = contract else {+            Issue.record("expected a place 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")+        #expect(preselected == variants.first?.id)++        let chosen = try #require(variants.first { $0.note == "quay" })+        let outcome = try await fixture.repository.commitDuplicateResolution(+            contract, choosing: chosen.id)+        #expect(outcome == .committed(survivorID: Self.harbour))++        let rows = try await fixture.repository.m5PlaceRows(id: Self.harbour)+        #expect(rows.count == 2, "resolution converges the rows; it never deletes one")+        #expect(Set(rows.map(\.note)) == ["quay"])+        #expect(rows.allSatisfy { $0.aliases == ["The Quay"] },+                "the chosen variant's aliases are written, not a union of both")+        withExtendedLifetime(fixture) {}+    }++    /// Req 5.3's other half: a torn place refuses the backup export the way a+    /// torn character does. Without the projection arm there is no site for that+    /// refusal at all and one variant would export silently.+    @Test("A torn place group refuses the backup export")+    func tornPlaceRefusesExport() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "docks", workID: Self.workID),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "quay", workID: Self.workID),+        ])++        await #expect(throws: BackupV12ExportError.self) {+            _ = try await fixture.repository.backupV12Snapshot()+        }+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Entry collapse repoints both kinds (Req 3.6, Q85)", .serialized)+struct PlaceCitationRepointingTests {++    private static let workID = UUID(uuidString: "0F300000-0000-4000-8000-000000000001")!+    private static let survivor = UUID(uuidString: "0F300000-0000-4000-8000-00000000000A")!+    private static let loser = UUID(uuidString: "0F300000-0000-4000-8000-00000000000B")!+    private static let harbour = UUID(uuidString: "0F300000-0000-4000-8000-00000000000C")!+    private static let alex = UUID(uuidString: "0F300000-0000-4000-8000-00000000000D")!++    private func fact(_ id: UUID, key: String) -> RecordFact {+        RecordFact(statement: "Seen", quote: "at the harbour", nameKey: key, source: .entry(id))+    }++    /// The reader-confirmed entry collapse, end to end: the losing Entry row goes+    /// and **both** kinds' citations follow the survivor, fanned across every row+    /// of each group so neither can false-tear.+    @Test("A reader-confirmed entry collapse repoints character and place citations together")+    func collapseRepointsBothKinds() async throws {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "p.example")],+            works: [M5SeedWork(+                id: Self.workID, displayTitle: "A Serial", hostname: "p.example")],+            entries: [+                M5SeedEntry(+                    id: Self.survivor, captureTitle: "Ch 1", hostname: "p.example", path: "1",+                    note: "kept", firstCapturedAt: M5Fixture.epoch, workID: Self.workID),+                M5SeedEntry(+                    id: Self.loser, captureTitle: "Ch 1", hostname: "p.example", path: "1",+                    note: "dropped",+                    firstCapturedAt: M5Fixture.epoch.addingTimeInterval(60),+                    workID: Self.workID),+            ],+            characters: [M5SeedCharacter(+                id: Self.alex, name: "Alex", nameKey: "alex",+                facts: [fact(Self.loser, key: "alex")], workID: Self.workID)],+            places: [+                M5SeedPlace(+                    id: Self.harbour, name: "Harbour", nameKey: "harbour",+                    facts: [fact(Self.loser, key: "harbour")], workID: Self.workID),+                M5SeedPlace(+                    id: Self.harbour, name: "Harbour", nameKey: "harbour",+                    facts: [fact(Self.loser, key: "harbour")], workID: Self.workID),+            ],+            placeSuppressions: [M5SeedPlaceSuppression(+                workID: Self.workID, kind: .fact, nameKey: "harbour",+                source: .entry(Self.loser), evidence: "at the harbour")])++        let scan = try await fixture.repository.m5Scan()+        let key = try #require(scan.entrySets.first?.key)+        let contract = try await fixture.repository.projectDuplicateResolution(setKey: key)+        guard case .entry(_, let variants, _, _) = contract else {+            Issue.record("expected an entry contract, got \(contract)")+            return+        }+        let chosen = try #require(variants.first { $0.note == "kept" })+        let outcome = try await fixture.repository.commitDuplicateResolution(+            contract, choosing: chosen.id)+        #expect(outcome == .committed(survivorID: Self.survivor))++        let characters = try await fixture.repository.m5CharacterRows(id: Self.alex)+        #expect(characters.first?.facts.map(\.source) == [.entry(Self.survivor)],+                "the character half still moves")+        let places = try await fixture.repository.m5PlaceRows(id: Self.harbour)+        #expect(places.count == 2)+        #expect(places.allSatisfy { $0.facts.map(\.source) == [.entry(Self.survivor)] })+        #expect(Set(places.map(\.factsData)).count == 1, "the place group did not tear")+        #expect(try await fixture.repository.m5PlaceSuppressionRows().map(\.source)+            == [.entry(Self.survivor)],+            "a place suppression's source reference follows the surviving row too")+        withExtendedLifetime(fixture) {}+    }+}++@Suite("Places through work merge and deletion (Req 3.5)", .serialized)+struct PlaceWorkIntegrationTests {++    private static let workA = UUID(uuidString: "0F400000-0000-4000-8000-000000000001")!+    private static let workB = UUID(uuidString: "0F400000-0000-4000-8000-000000000002")!+    private static let entry1 = UUID(uuidString: "0F400000-0000-4000-8000-000000000011")!+    private static let harbour = UUID(uuidString: "0F400000-0000-4000-8000-00000000000A")!+    private static let lighthouse = UUID(uuidString: "0F400000-0000-4000-8000-00000000000B")!++    private func twoWorks() async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "p.example")],+            works: [+                M5SeedWork(+                    id: Self.workA, displayTitle: "Source", hostname: "p.example",+                    lastParsedTitle: "Source", genericNotes: "source notes"),+                M5SeedWork(+                    id: Self.workB, displayTitle: "Target", hostname: "p.example",+                    lastParsedTitle: "Target", genericNotes: "target notes"),+            ],+            entries: [M5SeedEntry(+                id: Self.entry1, captureTitle: "Ch 1", hostname: "p.example", path: "1",+                note: "The harbour", workID: Self.workA)])+        return fixture+    }++    @Test("A merge rewrites workID on every place and suppression row and names the count")+    func mergeMovesPlaces() async throws {+        let fixture = try await twoWorks()+        let earlier = M5Fixture.epoch.addingTimeInterval(-3_600)+        let later = M5Fixture.epoch.addingTimeInterval(3_600)+        try await fixture.repository.seedM5Rows(+            places: [+                // Cites the *generic notes*, which now resolve against the+                // target's: the group is re-encoded and stamped.+                M5SeedPlace(+                    id: Self.harbour, name: "Harbour", nameKey: "harbour",+                    facts: [RecordFact(+                        statement: "Introduced", quote: "source notes", nameKey: "harbour",+                        source: .genericNotes)],+                    workID: Self.workA, modifiedAt: earlier),+                // Cites an entry, which moved with the merge and still names the+                // row it always named: untouched, so its stamp survives for the+                // archive's import guard.+                M5SeedPlace(+                    id: Self.lighthouse, name: "Lighthouse", nameKey: "lighthouse",+                    facts: [RecordFact(+                        statement: "Seen", quote: "The harbour", nameKey: "lighthouse",+                        source: .entry(Self.entry1))],+                    workID: Self.workA, modifiedAt: later),+            ],+            placeSuppressions: [+                M5SeedPlaceSuppression(workID: Self.workA, nameKey: "ghost"),+            ])++        let contract = try await fixture.repository.projectMerge(+            sourceWorkID: Self.workA, targetWorkID: Self.workB)+        #expect(contract.outcome.movedPlaceCount == 2, "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 places = try await fixture.repository.m5AllPlaces()+        #expect(places.allSatisfy { $0.workID == Self.workB }, "every row names the survivor")+        let harbour = try #require(places.first { $0.id == Self.harbour })+        #expect(harbour.modifiedAt == M5Fixture.epoch,+                "a generic-notes citation now resolves against the target's notes")+        let lighthouse = try #require(places.first { $0.id == Self.lighthouse })+        #expect(lighthouse.modifiedAt == later,+                "an untouched place keeps its stamp for the import guard")++        let suppressions = try await fixture.repository.m5PlaceSuppressionRows()+        #expect(suppressions.map(\.workID) == [Self.workB], "place suppressions union too")+        withExtendedLifetime(fixture) {}+    }++    @Test("Deleting a work deletes its places and place suppressions with it")+    func deletionCascades() async throws {+        let fixture = try await twoWorks()+        try await fixture.repository.seedM5Rows(+            places: [M5SeedPlace(+                id: Self.harbour, name: "Harbour", nameKey: "harbour", workID: Self.workA)],+            placeSuppressions: [+                M5SeedPlaceSuppression(workID: Self.workA, nameKey: "ghost"),+            ])++        let contract = try await fixture.repository.projectWorkDeletion(workID: Self.workA)+        let outcome = try await fixture.repository.commitWorkDeletion(+            contract, disposition: .deleteEntries, disclosedVariants: nil)+        #expect(outcome == .committed)++        #expect(try await fixture.repository.m5AllPlaces().isEmpty)+        #expect(try await fixture.repository.m5PlaceSuppressionRows().isEmpty,+                "an orphaned suppression would be inert for ever and in every backup")+        withExtendedLifetime(fixture) {}+    }++    /// The deletion stages its place rows through a predicate fetch, so the+    /// rollback a refused commit performs restores them rather than crashing on+    /// a future-backed row.+    @Test("A refused deletion rolls back and leaves the places intact")+    func refusedDeletionRollsBack() async throws {+        let fixture = try await M5Fixture()+        // Taught with no active title rule: the tuple table's own illegal shape,+        // which the hostname-scoped validator reports the moment it looks.+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "tuple.test", mode: .taught)],+            works: [M5SeedWork(+                id: Self.workA, displayTitle: "A Serial", hostname: "tuple.test")],+            places: [M5SeedPlace(+                id: Self.harbour, name: "Harbour", nameKey: "harbour", workID: Self.workA)],+            placeSuppressions: [+                M5SeedPlaceSuppression(workID: Self.workA, nameKey: "ghost"),+            ])++        let contract = try await fixture.repository.projectWorkDeletion(workID: Self.workA)+        let outcome = try await fixture.repository.commitWorkDeletion(+            contract, disposition: .deleteEntries, disclosedVariants: nil)+        guard case .invalidated = outcome else {+            Issue.record("expected an invalidated outcome, got \(outcome)")+            return+        }+        #expect(try await fixture.repository.m5AllPlaces().map(\.id) == [Self.harbour])+        #expect(try await fixture.repository.m5PlaceSuppressionRows().count == 1)+        withExtendedLifetime(fixture) {}+    }+}++// MARK: - The pre-feature deletion path++extension LibraryRepository {++    /// Deletes a Work's rows and nothing else, which is what a build that cannot+    /// see `Place` does when the reader deletes a work on it (Decision 2). The+    /// places it leaves behind are the permanent orphans Req 5.5 tolerates.+    func m5DeleteWorkRowsOnly(workID: UUID) async throws {+        try await withLockedContext(+            mode: .exclusive, operation: "deleting Work rows as a pre-feature build would"+        ) { context in+            for row in try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID })) {+                context.delete(row)+            }+            try context.save()+        }+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swift Added +361 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swiftnew file mode 100644index 0000000..9fd8a85--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecordGroupTests.swift@@ -0,0 +1,361 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Req 5.3: `RecordGroup<Row>` is the one logical-record type, and the character+// instantiation must behave exactly as the hand-written `CharacterGroup` did.+//+// The character suites are the regression net for the renames; what is pinned+// here is that the *generic* shape — group, repointing, ranking — answers the+// same way when it is reached through the type parameter rather than through a+// concrete character function.++@Suite("RecordGroup over Character (Req 5.3)")+struct RecordGroupTests {++    private static let epoch = Date(timeIntervalSince1970: 1_800_000_000)+    private static let hanna = UUID(uuidString: "0E000000-0000-4000-8000-000000000401")!+    private static let loser = UUID(uuidString: "0E000000-0000-4000-8000-000000000402")!+    private static let survivor = UUID(uuidString: "0E000000-0000-4000-8000-000000000403")!++    private func fact(_ source: SourceRef, quote: String = "she led") -> RecordFact {+        RecordFact(statement: "Leads", quote: quote, nameKey: "hanna", source: source)+    }++    private func row(+        id: UUID = RecordGroupTests.hanna,+        name: String = "Hanna",+        note: String = "",+        facts: [RecordFact] = [],+        createdAt: Date = RecordGroupTests.epoch,+        modifiedAt: Date = RecordGroupTests.epoch+    ) -> CharacterRecord {+        let record = CharacterRecord(+            id: id, name: name, nameKey: RecordNameKey.normalize(name), note: note,+            facts: facts, timestamp: createdAt)+        record.modifiedAt = modifiedAt+        return record+    }++    private func group(_ rows: [CharacterRecord]) throws -> RecordGroup<CharacterRecord> {+        try #require(LibraryRepository.recordGroup(id: rows[0].recordID, rows: rows))+    }++    /// Rows sharing a UUID and agreeing about everything authored are one+    /// record with one variant — the group converges rather than tearing.+    @Test("Same-UUID rows that agree form one untorn group")+    func agreeingRowsFormOneVariant() throws {+        let group = try group([+            row(note: "the lead", facts: [fact(.genericNotes)]),+            row(note: "the lead", facts: [fact(.genericNotes)]),+        ])++        #expect(group.id == Self.hanna)+        #expect(group.isSplit)+        #expect(!group.isTorn)+        #expect(group.variants.count == 1)+        #expect(group.authoredContent?.note == "the lead")+        #expect(group.presentedContent.name == "Hanna")+        #expect(group.presentedContent.facts.map(\.quote) == ["she led"])+        #expect(group.rows.count == 2)+    }++    /// Authored content is never bare for a record, so any disagreement tears —+    /// the structurally higher tear rate Decision 1 accepts.+    @Test("Rows disagreeing about authored content are torn, and present no single content")+    func disagreeingRowsTear() throws {+        let group = try group([+            row(note: "one"),+            row(note: "two"),+        ])++        #expect(group.isTorn)+        #expect(group.variants.count == 2)+        #expect(group.authoredContent == nil)+        #expect(group.variantIDs.count == 2)+        if case .torn(let variants) = group.state {+            #expect(variants.count == 2)+        } else {+            Issue.record("a torn group's state is .torn")+        }+    }++    /// The group's timestamps are the record's, not any one row's: earliest+    /// creation, latest modification.+    @Test("createdAt is the earliest row's and modifiedAt the latest row's")+    func timestampsSpanTheGroup() throws {+        let early = Self.epoch+        let late = Self.epoch.addingTimeInterval(3_600)+        let group = try group([+            row(note: "the lead", createdAt: early, modifiedAt: early),+            row(note: "the lead", createdAt: late, modifiedAt: late),+        ])++        #expect(group.createdAt == early)+        #expect(group.modifiedAt == late)+    }++    /// A single row is its own group, and `recordGroups` buckets by application+    /// UUID and nothing else (Q76).+    @Test("recordGroups buckets by application UUID")+    func groupsBucketByUUID() throws {+        let other = UUID(uuidString: "0E000000-0000-4000-8000-000000000404")!+        let groups = LibraryRepository.recordGroups([+            row(note: "one"),+            row(note: "two"),+            row(id: other, name: "Bruce"),+        ])++        #expect(Set(groups.keys) == [Self.hanna, other])+        #expect(groups[Self.hanna]?.rows.count == 2)+        #expect(groups[other]?.isSplit == false)+    }++    /// Q85, reached generically: a repoint computed from the group's presented+    /// facts lands on every row, so bookkeeping cannot manufacture a tear.+    @Test("CitationRepointing fans a rewrite out to every row of the group")+    func repointingFansOutAcrossTheGroup() throws {+        let rows = [+            row(facts: [fact(.entry(Self.loser))]),+            row(facts: [fact(.entry(Self.loser))]),+        ]+        let suppressions: [CharacterSuppression] = [+            CharacterSuppression(+                kind: .fact, nameKey: "hanna", source: .entry(Self.loser), evidence: "she led"),+        ]++        let changed = CitationRepointing.repoint(+            rows: rows, suppressions: suppressions,+            survivors: [Self.loser: Self.survivor],+            timestamp: Self.epoch.addingTimeInterval(60))++        #expect(changed == 3)+        #expect(rows.allSatisfy { $0.facts.map(\.source) == [.entry(Self.survivor)] })+        #expect(Set(rows.map(\.factsData)).count == 1, "the group did not tear")+        #expect(suppressions.map(\.sourceEntryID) == [Self.survivor])+    }++    /// A torn group is rewritten row by row instead: forcing one presented value+    /// would silently resolve a tear the reader owes a decision on.+    @Test("A torn group is repointed from each row's own facts")+    func repointingATornGroupKeepsTheTear() throws {+        let rows = [+            row(note: "one", facts: [fact(.entry(Self.loser), quote: "a")]),+            row(note: "two", facts: [fact(.entry(Self.loser), quote: "b")]),+        ]++        CitationRepointing.repoint(+            rows: rows, suppressions: [] as [CharacterSuppression],+            survivors: [Self.loser: Self.survivor], timestamp: Self.epoch)++        #expect(rows.allSatisfy { $0.facts.map(\.source) == [.entry(Self.survivor)] })+        #expect(rows.map(\.facts.first?.quote) == ["a", "b"], "each row kept its own facts")+        #expect(try group(rows).isTorn)+    }++    /// `RecordRanking.rankGroups` reached through the group type answers with the+    /// order `CharacterRankingTests` asserts: facts first by decayed score, then+    /// name order, and every record with no facts last.+    @Test("RecordRanking.rankGroups orders records by prominence, then name")+    func rankingOrdersByProminence() throws {+        let entries = (0..<3).map { _ in UUID() }+        let index = StoryPositionIndex(entries: entries.enumerated().map { ordinal, id in+            StoryPositionIndex.EntryInput(+                id: id,+                placement: nil,+                firstCapturedAt: Self.epoch.addingTimeInterval(TimeInterval(ordinal * 60)))+        })++        // `recent` cites the last position (distance 0), `old` the first+        // (distance 2), and `silent` has no facts at all.+        let recent = row(+            id: UUID(uuidString: "0E000000-0000-4000-8000-00000000041A")!, name: "Recent",+            facts: [fact(.entry(entries[2]), quote: "now")])+        let old = row(+            id: UUID(uuidString: "0E000000-0000-4000-8000-00000000041B")!, name: "Old",+            facts: [fact(.entry(entries[0]), quote: "then")])+        let silent = row(+            id: UUID(uuidString: "0E000000-0000-4000-8000-00000000041C")!, name: "Aaron")++        let groups = LibraryRepository.recordGroups([recent, old, silent])+        let ranked = RecordRanking.rankGroups(groups, index: index)++        #expect(ranked.map(\.presentedContent.name) == ["Recent", "Old", "Aaron"])+        #expect(RecordRanking.rank(groups, index: index).map(\.group.presentedContent.name)+            == ["Recent", "Old", "Aaron"],+            "the facts-carrying order and the names-only order are one comparator")+    }+}++// Req 5.3 over the *second* conformance. The character suite above pins that the+// generic shape answers as the hand-written one did; this one pins the half a+// character can never exercise — a table reached by a UUID column, whose owner+// may resolve to nothing and whose rows may disagree about who owns them.++@Suite("RecordGroup over Place (Req 5.3, 5.5)", .serialized)+struct PlaceRecordGroupTests {++    private static let workA = UUID(uuidString: "0F000000-0000-4000-8000-000000000001")!+    private static let workB = UUID(uuidString: "0F000000-0000-4000-8000-000000000002")!+    /// Names no `Work`: the tolerated orphan of Req 5.5.+    private static let absentWork = UUID(uuidString: "0F000000-0000-4000-8000-0000000000FF")!+    private static let harbour = UUID(uuidString: "0F000000-0000-4000-8000-00000000000A")!+    private static let orphan = UUID(uuidString: "0F000000-0000-4000-8000-00000000000B")!++    private func seeded(_ places: [M5SeedPlace]) async throws -> M5Fixture {+        let fixture = try await M5Fixture()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "p.example")],+            works: [+                M5SeedWork(id: Self.workA, displayTitle: "A Serial", hostname: "p.example"),+                M5SeedWork(id: Self.workB, displayTitle: "Another", hostname: "p.example"),+            ],+            places: places)+        return fixture+    }++    @Test("Same-UUID place rows that agree form one untorn group")+    func agreeingRowsFormOneVariant() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "the docks", workID: Self.workA),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "the docks", workID: Self.workA),+        ])++        let rows = try await fixture.repository.m5PlaceRows(id: Self.harbour)+        #expect(rows.count == 2)+        let group = try await fixture.repository.m5PlaceGroup(id: Self.harbour)+        #expect(group?.isSplit == true)+        #expect(group?.isTorn == false)+        #expect(group?.presentedContent.note == "the docks")+        withExtendedLifetime(fixture) {}+    }++    @Test("Place rows disagreeing about authored content are torn")+    func disagreeingRowsTear() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "one", workID: Self.workA),+            M5SeedPlace(id: Self.harbour, name: "Harbour", note: "two", workID: Self.workA),+        ])++        let group = try await fixture.repository.m5PlaceGroup(id: Self.harbour)+        #expect(group?.isTorn == true)+        #expect(group?.variants.count == 2)+        #expect(group?.authoredContent == nil)+        withExtendedLifetime(fixture) {}+    }++    /// The whole reason `RecordRow` owns `rows(ids:)` beside `rows(of:)` (Q52):+    /// a row whose `workID` resolves to nothing is invisible to the work-scoped+    /// read and still reachable by identity.+    @Test("rows(of:) hides an orphan and rows(ids:) reaches it")+    func orphanIsReachableByIDOnly() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workA),+            M5SeedPlace(id: Self.orphan, name: "Nowhere", workID: Self.absentWork),+        ])++        let owned = try await fixture.repository.m5PlaceIDs(ofWork: Self.workA)+        #expect(owned == [Self.harbour], "the orphan belongs to no work the library holds")+        #expect(try await fixture.repository.m5PlaceRows(id: Self.orphan).count == 1,+                "and is still reachable by its record id")+        withExtendedLifetime(fixture) {}+    }++    /// `Place.rows(of:)` returns materialised rows, so a caller may delete them+    /// and roll back — which is exactly what a refused commit does. An inverse+    /// array hands back future-backed rows and crashes SwiftData in snapshot+    /// creation on that path.+    @Test("Deleting rows fetched by predicate and rolling back leaves them intact")+    func deleteThenRollbackSurvives() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workA),+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workA),+        ])++        let deleted = try await fixture.repository.m5DeletePlacesThenRollback(ofWork: Self.workA)+        #expect(deleted == 2)+        #expect(try await fixture.repository.m5PlaceRows(id: Self.harbour).count == 2,+                "the rollback restored every row")+        withExtendedLifetime(fixture) {}+    }++    /// Ownership sits outside authored content (`GroupOrdering.authoredContent`),+    /// so a group whose rows name two works is **not** torn — it is one record+    /// that shows under each of them, the state characters already tolerate.+    @Test("A group whose rows disagree about workID is not torn and appears under each work")+    func disagreeingOwnersDoNotTear() async throws {+        let fixture = try await seeded([+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workA),+            M5SeedPlace(id: Self.harbour, name: "Harbour", workID: Self.workB),+        ])++        let group = try await fixture.repository.m5PlaceGroup(id: Self.harbour)+        #expect(group?.isTorn == false, "who owns a row is not something the reader authored")+        #expect(group?.rows.count == 2)+        #expect(try await fixture.repository.m5PlaceIDs(ofWork: Self.workA) == [Self.harbour])+        #expect(try await fixture.repository.m5PlaceIDs(ofWork: Self.workB) == [Self.harbour])+        withExtendedLifetime(fixture) {}+    }+}++// MARK: - Reading places out of the actor++extension LibraryRepository {++    /// The place group for one record id, as a value shape a test can assert on.+    func m5PlaceGroup(id: UUID) async throws -> M5RecordGroupFacts? {+        try await withLockedContext(mode: .shared, operation: "reading a place group") { context in+            let rows = try LibraryRepository.recordRows(Place.self, ids: [id], context: context)[id]+            guard let rows, let group = LibraryRepository.recordGroup(id: id, rows: rows)+            else { return nil }+            return M5RecordGroupFacts(group)+        }+    }++    /// The record ids `Place.rows(of:)` reaches for one work, sorted.+    func m5PlaceIDs(ofWork workID: UUID) async throws -> [UUID] {+        try await withLockedContext(mode: .shared, operation: "reading a work's places") { context in+            let works = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            return Array(Set(try Place.rows(of: works, context: context).map(\.recordID)))+                .sorted { $0.uuidString < $1.uuidString }+        }+    }++    /// Deletes every place row of a work through the conformance's fetch and+    /// rolls the context back, reporting how many were staged.+    func m5DeletePlacesThenRollback(ofWork workID: UUID) async throws -> Int {+        try await withLockedContext(+            mode: .exclusive, operation: "staging a place deletion"+        ) { context in+            let works = try context.fetch(+                FetchDescriptor<Work>(predicate: #Predicate { $0.id == workID }))+            let rows = try Place.rows(of: works, context: context)+            for row in rows { context.delete(row) }+            context.rollback()+            return rows.count+        }+    }+}++/// A `RecordGroup`'s answers as a `Sendable` value — `@Model` rows may not leave+/// the actor, and every property under test here is derived.+struct M5RecordGroupFacts: Sendable {+    let isSplit: Bool+    let isTorn: Bool+    let variants: [RecordAuthoredContent]+    let authoredContent: RecordAuthoredContent?+    let presentedContent: RecordAuthoredContent+    let rows: [RecordAuthoredContent]++    init<Row: RecordRow>(_ group: RecordGroup<Row>) {+        isSplit = group.isSplit+        isTorn = group.isTorn+        variants = group.variants.map(\.content)+        authoredContent = group.authoredContent+        presentedContent = group.presentedContent+        rows = group.rows.map(GroupOrdering.authoredContent(of:))+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swift Added +170 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swiftnew file mode 100644index 0000000..0728bf3--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RecordRowTests.swift@@ -0,0 +1,170 @@+import Foundation+import SwiftData+import Testing++@testable import AsterismCore++// Req 5.3: the store code is generic over `RecordRow`, and `CharacterRecord` is+// the first conformance.+//+// What these pin is the *seam*, not the character behaviour the rest of the+// suites already cover: the conformance owns its fetches (an inverse walk here,+// a predicate there), it vends the application UUID under a name a generic+// `row.id` cannot shadow, and its archive hooks behave the way the importer's+// hand-written arms behave today. Every predicate lives inside a conformance;+// generic code writes none (design §Store generics, Risk 1).++@Suite("RecordRow on Character (Req 5.3)", .serialized)+struct RecordRowTests {++    private static let workID = UUID(uuidString: "0E000000-0000-4000-8000-000000000301")!+    private static let hanna = UUID(uuidString: "0E000000-0000-4000-8000-00000000030A")!+    private static let bruce = UUID(uuidString: "0E000000-0000-4000-8000-00000000030B")!+    private static let absent = UUID(uuidString: "0E000000-0000-4000-8000-00000000030F")!++    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+    }++    /// `recordID`, not `id`: `PersistentModel` already vends+    /// `id: PersistentIdentifier`, so a generic `row.id` is ambiguous — which+    /// `prototype/generic-store-spike` is the proof of.+    @Test("A character names its kind and vends the application UUID as recordID")+    func kindAndRecordID() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(id: Self.hanna, name: "Hanna", workID: Self.workID),+        ])++        #expect(CharacterRecord.kind == .character)+        try await fixture.repository.readingRows { context in+            let rows = try CharacterRecord.rows(ids: [Self.hanna], context: context)+            #expect(rows.map(\.recordID) == [Self.hanna])+            #expect(rows.first?.ownerWorkID == Self.workID)+        }+        withExtendedLifetime(fixture) {}+    }++    /// The character conformance reaches its rows through `work.characters`, so+    /// a work row handed over twice — which a split work group does hand over —+    /// must not yield its characters twice.+    @Test("rows(of:) walks the inverse and dedups by object identity")+    func rowsOfWorksDedupByObjectIdentity() async throws {+        let fixture = try await seeded([+            M5SeedCharacter(id: Self.hanna, name: "Hanna", workID: Self.workID),+            M5SeedCharacter(id: Self.bruce, name: "Bruce", workID: Self.workID),+        ])++        try await fixture.repository.readingRows { context in+            let works = try context.fetch(FetchDescriptor<Work>())+            let once = try CharacterRecord.rows(of: works, context: context)+            let twice = try CharacterRecord.rows(of: works + works, context: context)+            #expect(Set(once.map(\.recordID)) == [Self.hanna, Self.bruce])+            #expect(twice.count == once.count, "one character reached twice is one character")+        }+        withExtendedLifetime(fixture) {}+    }++    /// The convergence fan-out fetches by id, never by work: ownership sits+    /// outside authored content, so a group whose rows disagree about their+    /// owner is not torn and has to be reachable whole.+    @Test("rows(ids:) is a predicate fetch reaching every row of an id group")+    func rowsByIDIsAPredicateFetch() async throws {+        let fixture = try await seeded([+            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", workID: Self.workID),+        ])++        try await fixture.repository.readingRows { context in+            let group = try CharacterRecord.rows(ids: [Self.hanna], context: context)+            #expect(group.count == 2)+            #expect(Set(group.map(\.note)) == ["one", "two"])+            #expect(try CharacterRecord.rows(ids: [Self.absent], context: context).isEmpty)+            #expect(try CharacterRecord.rows(ids: [], context: context).isEmpty)+        }+        withExtendedLifetime(fixture) {}+    }++    /// The importer's rule, as the protocol states it: ownership is applied+    /// separately from content, and a nil target leaves the relationship the row+    /// already has alone rather than orphaning it (`BackupImportRecords`).+    @Test("make(imported:) carries the record, and attach(to: nil) leaves ownership alone")+    func importedRecordAttaches() async throws {+        let fixture = try await seeded([])+        let record = BackupV12Character(+            id: Self.hanna, workID: Self.workID, name: "Hanna", nameKey: "hanna",+            aliases: ["Hanne"], note: "the lead",+            facts: [RecordFact(+                statement: "Leads", quote: "she led", nameKey: "hanna", source: .genericNotes)],+            createdAt: M5Fixture.epoch, modifiedAt: M5Fixture.epoch.addingTimeInterval(60))++        try await fixture.repository.readingRows { context in+            let works = try context.fetch(FetchDescriptor<Work>())+            let row = CharacterRecord.make(imported: record)+            #expect(row.recordID == Self.hanna)+            #expect(row.nameKey == "hanna")+            #expect(row.aliases == ["Hanne"])+            #expect(row.facts.map(\.quote) == ["she led"])+            #expect(row.modifiedAt == M5Fixture.epoch.addingTimeInterval(60))+            #expect(row.ownerWorkID == nil)++            row.attach(to: works.first, archivedWorkID: record.workID)+            #expect(row.ownerWorkID == Self.workID)++            row.attach(to: nil, archivedWorkID: nil)+            #expect(row.ownerWorkID == Self.workID, "a nil target never detaches a character")+        }+        withExtendedLifetime(fixture) {}+    }++    /// The suppression half of the same seam.+    @Test("A character suppression names its kind and carries its archived columns")+    func suppressionConformance() 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")],+            suppressions: [+                M5SeedSuppression(workID: Self.workID, kind: .candidate, nameKey: "hanna"),+            ])++        #expect(CharacterSuppression.kind == .character)+        try await fixture.repository.readingRows { context in+            let works = try context.fetch(FetchDescriptor<Work>())+            let rows = try CharacterSuppression.rows(of: works, context: context)+            #expect(rows.map(\.nameKey) == ["hanna"])+            #expect(rows.first?.ownerWorkID == Self.workID)+            #expect(rows.first?.kindRaw == CharacterSuppressionKind.candidate.rawValue)+            #expect(rows.first?.statusRaw == CharacterSuppressionStatus.active.rawValue)++            let imported = CharacterSuppression.make(imported: BackupV12Suppression(+                id: Self.absent, workID: Self.workID, kindRaw: "fact", nameKey: "bruce",+                sourceKindRaw: "genericNotes", sourceEntryID: nil, evidence: "he left",+                statusRaw: "cleared", actionAt: M5Fixture.epoch))+            #expect(imported.recordID == Self.absent)+            #expect(imported.kindRaw == "fact")+            #expect(imported.statusRaw == "cleared")+            #expect(imported.ownerWorkID == nil)+            imported.attach(to: nil, archivedWorkID: Self.workID)+            #expect(imported.ownerWorkID == nil, "a nil target attaches nothing")+        }+        withExtendedLifetime(fixture) {}+    }+}++// MARK: - Driving a read from a suite++extension LibraryRepository {+    /// A shared locked context, so a suite can call the conformances' own+    /// fetches the way the repository calls them.+    func readingRows(_ body: (ModelContext) throws -> Void) async throws {+        try await withLockedContext(mode: .shared, operation: "reading record rows", body)+    }+}
Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swiftindex 0a6aed2..76c54e4 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RefreshUnionInvariantTests.swift@@ -19,7 +19,7 @@ import Testing /// /// What that would actually re-enable is asserted here rather than assumed: /// capture would start applying an illegal Site's rules again-/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV11Exporter.swift:41` would+/// (`+ReparseCapture.swift:284`, `:396`), and `BackupV12Exporter.swift:41` would /// stop gating. The four Req 3.4 write-path guards read /// `diagnostics.diagnoses` for `.duplicateSiteRows` (Q41), a class the scan does /// re-derive, so they are the weaker half of the assertion — pinned anyway,@@ -108,7 +108,7 @@ struct RefreshUnionInvariantTests {         #expect(await repository.quarantineReason(hostname: tupleHost) != nil)         for attempt in 0...2 {             if attempt > 0 { try await repository.refreshDiagnostics() }-            let payload = try await repository.backupV11Snapshot()+            let payload = try await repository.backupV12Snapshot()             // One wire Site per hostname, including the duplicated one and the             // rowless one (Q38, Q40).             #expect(Set(payload.sites.map(\.hostname))
Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift Modified +1 / -1
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swiftindex 2bc5f14..54fdfe6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/RuleSelectionTests.swift@@ -98,7 +98,7 @@ struct RuleSelectionTests {     /// hands back the same registered objects in the same order every time,     /// which is a property of that context, not of the accessor.     private func container() throws -> ModelContainer {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         return try ModelContainer(             for: schema,             configurations: [ModelConfiguration(
Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift Modified +7 / -7
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swiftindex 082f6bb..2f8ba1a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/ShareWorkContextTests.swift@@ -32,9 +32,9 @@ struct ShareCharacterRowTests {      @Test("An alias list is drawn as stored — sorted, not re-ordered here")     func aliasOrderIsTheStoredOrder() {-        // `CharacterAuthoredContent` sorts aliases at construction, so the row+        // `RecordAuthoredContent` sorts aliases at construction, so the row         // has nothing left to decide; it must not impose a second order.-        let content = CharacterAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])+        let content = RecordAuthoredContent(name: "Hanna", aliases: ["Zephyr", "Ann"])         let text = ShareCharacterRow.text(for: [             ShareCharacter(name: content.name, aliases: content.aliases)         ])@@ -193,9 +193,9 @@ struct ShareWorkContextReadTests {     /// string literals. `nameKey` is the character's own: the ranking counts a     /// group's presented facts, whatever they are keyed under.     private static func fact(_ name: String, _ statement: String, citing source: SourceRef)-        -> CharacterFact+        -> RecordFact     {-        CharacterFact(+        RecordFact(             statement: statement, quote: "\(name) \(statement)",             nameKey: name.lowercased(), source: source)     }@@ -1008,10 +1008,10 @@ struct ShareWorkContextLookupTests {         M5SeedCharacter(             id: bob, name: "Bob", nameKey: "bob",             facts: [-                CharacterFact(+                RecordFact(                     statement: "carries the rope", quote: "Bob carries the rope",                     nameKey: "bob", source: .entry(entryID)),-                CharacterFact(+                RecordFact(                     statement: "keeps the lamp", quote: "Bob keeps the lamp",                     nameKey: "bob", source: .entry(entryID)),             ],@@ -1019,7 +1019,7 @@ struct ShareWorkContextLookupTests {         M5SeedCharacter(             id: alice, name: "alice", nameKey: "alice", aliases: ["Ally", "Al"],             facts: [-                CharacterFact(+                RecordFact(                     statement: "waits on the pier", quote: "alice waits on the pier",                     nameKey: "alice", source: .entry(entryID))             ],
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 6e0f07b..1074cbf 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteReconcilerTests.swift@@ -453,12 +453,12 @@ private final class ReconcilerStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismSiteReconciler-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)         self.saveStrategy = saveStrategy ?? saveRecorder
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 65a008b..45ef36a 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/SiteUnionProjectionTests.swift@@ -376,12 +376,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: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift Modified +3 / -3
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swiftindex 67e2144..e71e597 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/StoreMetadataTests.swift@@ -187,17 +187,17 @@ struct StoreMetadataTests {     @Test("A conversion still in the write-ahead log is read from the log, not the main file")     func uncommittedConversionIsReadFromTheLog() throws {         let dir = try TempDir()-        try V11RecordedStoreFixture.install(at: dir.storeURL)+        try V12RecordedStoreFixture.install(at: dir.storeURL)         #expect(StoreMetadata.recordedVersion(at: dir.storeURL) == .atOrAboveV5,                 "the premise: the seed is a store the classifier admits")-        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["11.0.0"])+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["12.0.0"])          let container = try LibraryRepository.openContainer(at: dir.storeURL)         _ = ModelContext(container)         try #require(FileManager.default.fileExists(atPath: dir.storeURL.path + "-wal"),                      "the conversion has to be in the log for this to be the hazard") -        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["12.0.0"],+        #expect(try V4RecordedStoreFixture.recordedModelVersions(at: dir.storeURL) == ["13.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 +15 / -15
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swiftindex 58bf57f..590e2cb 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/URLOptionalSequenceTests.swift@@ -890,7 +890,7 @@ struct URLOptionalSequenceReconcilerTests { @Suite("Optional chapter sequence — archive") struct URLOptionalSequenceArchiveTests { -  private static func combinedRule(of payload: BackupV11Payload) throws -> URLTwoFieldTemplate? {+  private static func combinedRule(of payload: BackupV12Payload) throws -> URLTwoFieldTemplate? {     guard case .combined(_, let template) = try #require(payload.urlRules.first).definition else {       return nil     }@@ -903,12 +903,12 @@ struct URLOptionalSequenceArchiveTests {   /// so this asserts the asymmetric `Codable` from the reading side.   @Test("A pre-feature archive decodes, and its combined rule is still required")   func preFeatureArchiveDecodes() throws {-    let document = BackupV11Fixtures.sequencePresenceOmittedDocument()+    let document = BackupV12Fixtures.sequencePresenceOmittedDocument()     #expect(!String(decoding: document, as: UTF8.self).contains("sequencePresence")) -    let decoded = try BackupV11Codec.decode(document)+    let decoded = try BackupV12Codec.decode(document) -    #expect(decoded.payload == BackupV11Fixtures.combinedRulePayload(presence: .required))+    #expect(decoded.payload == BackupV12Fixtures.combinedRulePayload(presence: .required))     #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .required)   } @@ -917,17 +917,17 @@ struct URLOptionalSequenceArchiveTests {   /// this feature would produce — which is what keeps it importable there.   @Test("An archive with no declared-optional rule encodes the pre-feature bytes")   func requiredArchiveIsByteIdenticalToPreFeature() throws {-    let encoded = try BackupV11Codec.encode(-      payload: BackupV11Fixtures.combinedRulePayload(presence: .required),-      metadata: BackupV11Metadata(-        appBuild: "pre-feature", exportedAt: BackupV11Fixtures.created))+    let encoded = try BackupV12Codec.encode(+      payload: BackupV12Fixtures.combinedRulePayload(presence: .required),+      metadata: BackupV12Metadata(+        appBuild: "pre-feature", exportedAt: BackupV12Fixtures.created))     let json = String(decoding: encoded, as: UTF8.self)      #expect(!json.contains("sequencePresence"))     #expect(-      json.contains(BackupV11Fixtures.sequencePresenceOmittedPayloadJSON),+      json.contains(BackupV12Fixtures.sequencePresenceOmittedPayloadJSON),       "the exported payload is no longer the pre-feature payload")-    #expect(encoded == BackupV11Fixtures.sequencePresenceOmittedDocument())+    #expect(encoded == BackupV12Fixtures.sequencePresenceOmittedDocument())   }    /// Req 5.4: a declared-optional rule survives export and import unchanged. The@@ -935,17 +935,17 @@ struct URLOptionalSequenceArchiveTests {   /// `URLRulePattern` a reader would end up with, not merely a decoded value.   @Test("A declared-optional rule round-trips through export and import")   func optionalRuleRoundTripsThroughTheArchive() throws {-    let payload = BackupV11Fixtures.combinedRulePayload(presence: .optional)-    let encoded = try BackupV11Codec.encode(+    let payload = BackupV12Fixtures.combinedRulePayload(presence: .optional)+    let encoded = try BackupV12Codec.encode(       payload: payload,-      metadata: BackupV11Metadata(appBuild: "with-feature", exportedAt: BackupV11Fixtures.created))+      metadata: BackupV12Metadata(appBuild: "with-feature", exportedAt: BackupV12Fixtures.created))     #expect(String(decoding: encoded, as: UTF8.self).contains(#""sequencePresence":"optional""#)) -    let decoded = try BackupV11Codec.decode(encoded)+    let decoded = try BackupV12Codec.decode(encoded)     #expect(decoded.payload == payload)     #expect(try Self.combinedRule(of: decoded.payload)?.sequencePresence == .optional) -    let schema = Schema(versionedSchema: AsterismSchemaV12.self)+    let schema = Schema(versionedSchema: AsterismSchemaV13.self)     let container = try ModelContainer(       for: schema,       configurations: [
Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swift Renamed +141 / -94
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreFixture.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swiftsimilarity index 75%rename from Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreFixture.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swiftindex a965928..08e4ee0 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreFixture.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreFixture.swift@@ -3,35 +3,35 @@ import SwiftData  @testable import AsterismCore -/// A store genuinely **recorded at 11.0.0**, seeded in-process through the-/// frozen `AsterismSchemaV11` snapshot — the library a device that ran the V11-/// build holds on the morning of the V12 update.+/// A store genuinely **recorded at 12.0.0**, seeded in-process through the+/// frozen `AsterismSchemaV12` snapshot — the library a device that ran the V12+/// build holds on the morning of the V13 update. ///-/// It succeeds `V5`/`V6`/`V7`/`V8`/`V9`/`V10RecordedStoreFixture`, each of which-/// went with the stage that named it — V10's in this feature's freeze commit,-/// once every device was confirmed on marker `"11"` (`work-creators` Q15, the-/// `prerequisites.md` box, ticked 2026-09-07). It is now the only convertible-/// fixture in the package; anything older than V11 fails closed.+/// It succeeds `V5`/`V6`/`V7`/`V8`/`V9`/`V10`/`V11RecordedStoreFixture`, each of+/// which went with the stage that named it — V11's in this feature's freeze+/// commit, once every device was confirmed on marker `"12"`+/// (`place-extraction` Q48, the `prerequisites.md` box, ticked 2026-09-10). It+/// is now the only convertible fixture in the package; anything older than V12+/// fails closed. /// /// Seeding through the snapshot rather than committing a `.sqlite` is what the-/// nesting buys: a container over `AsterismSchemaV11` records 11.0.0 in the+/// nesting buys: a container over `AsterismSchemaV12` records 12.0.0 in the /// store's own metadata, and the fixture cannot drift out of sync with the /// snapshot it is built from. ///-/// **No `Creator`, `CreatorRole` or `WorkCredit` row exists**, because V11 has-/// no table to put one in. That is the whole point of this fixture at V12: what-/// the stage has to produce is three empty tables, with no column added to any-/// existing entity, no attribute default and no data pass behind it, and-/// `V11RecordedStoreTests` asserts it on raw fetches.+/// **No `Place` or `PlaceSuppression` row exists**, because V12 has no table to+/// put one in. That is the whole point of this fixture at V13: what the stage+/// has to produce is two empty tables, with no column added to any existing+/// entity, no attribute default and no data pass behind it, and+/// `V12RecordedStoreTests` asserts it on raw fetches. ///-/// The V11 additions the fixture *does* have — `Work.seriesID`,-/// `Work.seriesPosition`, one `Series` row and one `WorkLink` row — are seeded-/// with **non-default** values for the opposite reason, exactly as the V10-/// fixture seeded the status columns away from theirs: they are what the-/// previous stage supplied, and a conversion that re-applied nil over them would-/// be invisible if the fixture had left them nil.+/// The V12 additions the fixture *does* have — one `Creator`, one extra+/// `CreatorRole` beside the three the app seeds, and one `WorkCredit` — are+/// seeded for the opposite reason, exactly as the V11 fixture seeded `Series`+/// and `WorkLink`: they are what the previous stage supplied, and a conversion+/// that lost them would be invisible if the fixture had left those tables empty. ///-/// The rest of the inventory is the retired `V10RecordedStoreFixture`'s, carried+/// The rest of the inventory is the retired `V11RecordedStoreFixture`'s, carried /// forward unchanged: one row per entity, /// **except `Entry`, which gets three, and `TitlePattern`, which gets two**. /// Entry A carries the v2 identity arm and the URL-rule work assignment, Entry B@@ -69,102 +69,114 @@ import SwiftData /// **And the ordering is the only thing holding it up**, as it has been since /// V10. A stage that only *removed* left the live stored shape a subset of the /// frozen one, so a live key a stale registration could not answer did not-/// exist. V12 **adds**: `Creator`, `CreatorRole` and `WorkCredit` are whole-/// entities this snapshot cannot name at all. The create-seed-save-release-/// ordering below is what answers that.-enum V11RecordedStoreFixture {-    static let hostname = "frozen11.example"-    static let siteDisplayName = "Frozen Eleven"-    static let patternID = UUID(uuidString: "22222222-2222-2222-2222-00000000000b")!+/// exist. V13 **adds**: `Place` and `PlaceSuppression` are whole entities this+/// snapshot cannot name at all. The create-seed-save-release ordering below is+/// what answers that.+enum V12RecordedStoreFixture {+    static let hostname = "frozen12.example"+    static let siteDisplayName = "Frozen Twelve"+    static let patternID = UUID(uuidString: "22222222-2222-2222-2222-00000000000c")!     static let patternVersion = 5     /// The Site's second, retired title rule — the segment arm. Inactive,     /// because Decision 5 of `unified-teaching-composition` gives a taught Site     /// exactly one active rule, and on a Site-unique version     /// (`LibraryValidator`'s Site tuple).-    static let segmentPatternID = UUID(uuidString: "22222222-2222-2222-2222-00000000001b")!+    static let segmentPatternID = UUID(uuidString: "22222222-2222-2222-2222-00000000001c")!     static let segmentPatternVersion = 4-    static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-00000000000b")!+    static let urlRuleID = UUID(uuidString: "33333333-3333-3333-3333-00000000000c")!     static let urlRuleVersion = 3-    static let workID = UUID(uuidString: "44444444-4444-4444-4444-00000000000b")!-    static let membershipID = UUID(uuidString: "99999999-9999-9999-9999-00000000000b")!-    static let entryAID = UUID(uuidString: "55555555-5555-5555-5555-00000000000b")!-    static let entryBID = UUID(uuidString: "55555555-5555-5555-5555-00000000001b")!-    static let entryCID = UUID(uuidString: "55555555-5555-5555-5555-00000000002b")!-    static let distinctPairID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-00000000000b")!+    static let workID = UUID(uuidString: "44444444-4444-4444-4444-00000000000c")!+    static let membershipID = UUID(uuidString: "99999999-9999-9999-9999-00000000000c")!+    static let entryAID = UUID(uuidString: "55555555-5555-5555-5555-00000000000c")!+    static let entryBID = UUID(uuidString: "55555555-5555-5555-5555-00000000001c")!+    static let entryCID = UUID(uuidString: "55555555-5555-5555-5555-00000000002c")!+    static let distinctPairID = UUID(uuidString: "aaaaaaaa-aaaa-aaaa-aaaa-00000000000c")!     /// The other half of the seeded `WorkDistinctPair`. No `Work` carries it:     /// the pair table names Works by application UUID and nothing prunes a row     /// whose Work has gone, so a dangling id is a state the live library holds     /// and the conversion must carry across unchanged.-    static let distinctPairOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-00000000001b")!-    static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-00000000000b")!+    static let distinctPairOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-00000000001c")!+    static let workTypeID = UUID(uuidString: "66666666-6666-6666-6666-00000000000c")!     static let workTypeName = "Web Serial"-    static let characterID = UUID(uuidString: "77777777-7777-7777-7777-00000000000b")!-    static let characterName = "Eleven of Frozen"-    static let characterNameKey = "eleven of frozen"-    static let characterAliases = ["Eleven", "Frozen Eleven"]+    static let characterID = UUID(uuidString: "77777777-7777-7777-7777-00000000000c")!+    static let characterName = "Twelve of Frozen"+    static let characterNameKey = "twelve of frozen"+    static let characterAliases = ["Twelve", "Frozen Twelve"]     static let characterNote = "The one the fixture names."-    static let suppressionID = UUID(uuidString: "88888888-8888-8888-8888-00000000000b")!+    static let suppressionID = UUID(uuidString: "88888888-8888-8888-8888-00000000000c")!     static let suppressionNameKey = "the narrator"-    static let workName = "A Frozen Eleven"-    static let genericNotes = "generic notes, recorded at 11.0.0"-    static let workURLString = "https://frozen11.example/series/99"+    static let workName = "A Frozen Twelve"+    static let genericNotes = "generic notes, recorded at 12.0.0"+    static let workURLString = "https://frozen12.example/series/99"     static let workIdentity = "99"-    static let genreTags = ["frozen", "eleven"]+    static let genreTags = ["frozen", "twelve"]     static let timestamp = Date(timeIntervalSince1970: 1_845_000_000)      /// V10's three columns, seeded away from their defaults, exactly as the-    /// retired V10 fixture seeded them: a row carrying `ongoing` / `reading` /+    /// retired V11 fixture seeded them: a row carrying `ongoing` / `reading` /     /// `""` would not distinguish "the stage left it alone" from "something     /// wrote the default over it again".     static let workStatus = WorkStatus.finished     static let readingStatus = ReadingStatus.abandoned     static let verdict = "Read to the end; the middle drags." -    /// V11's own additions, seeded away from *their* defaults for the same-    /// reason: the work is in a series at a position, and a link and a series-    /// row exist. Both columns are optional, so nil is the default here.-    static let seriesID = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-00000000000b")!+    /// V11's additions, seeded away from *their* defaults for the same reason:+    /// the work is in a series at a position, and a link and a series row exist.+    /// Both columns are optional, so nil is the default here.+    static let seriesID = UUID(uuidString: "bbbbbbbb-bbbb-bbbb-bbbb-00000000000c")!     static let seriesName = "The Frozen Sequence"-    static let seriesNotes = "notes about the sequence, recorded at 11.0.0"+    static let seriesNotes = "notes about the sequence, recorded at 12.0.0"     static let seriesPosition = 2.5-    static let linkID = UUID(uuidString: "cccccccc-cccc-cccc-cccc-00000000000b")!+    static let linkID = UUID(uuidString: "cccccccc-cccc-cccc-cccc-00000000000c")!     /// The other end of the seeded link. No `Work` carries it, for the reason     /// the distinct pair's other id has none: a link outlives the absence of     /// either end, and the conversion must carry that state across.-    static let linkOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-00000000002b")!+    static let linkOtherWorkID = UUID(uuidString: "44444444-4444-4444-4444-00000000002c")!     static let linkType = "spin-off" +    /// V12's own three tables. The role is a **fourth** one beside the three+    /// `CreatorRoleSeeding` mints on every app open, so the seeding guard and+    /// the conversion are told apart: the seeded three are pristine at frozen+    /// identities, this one is the reader's.+    static let creatorID = UUID(uuidString: "dddddddd-dddd-dddd-dddd-00000000000c")!+    static let creatorName = "Mori Ayane"+    static let creatorNotes = "also publishes as M.A."+    static let creatorRoleID = UUID(uuidString: "eeeeeeee-eeee-eeee-eeee-00000000000c")!+    static let creatorRoleName = "letterer"+    static let creatorRolePosition = 7+    static let creditID = UUID(uuidString: "ffffffff-ffff-ffff-ffff-00000000000c")!+     static let trimPrefix = "Read: "-    static let trimSuffix = " | Frozen Eleven"+    static let trimSuffix = " | Frozen Twelve"     static let phraseSeparator = " — " -    static let entryANote = "Recorded at 11.0.0 ✓"+    static let entryANote = "Recorded at 12.0.0 ✓"     static let entryASequence = "11"     static let entryAChapterTitle = "Chapter 11"-    static let entryACaptureTitle = "Read: Chapter 11 — A Frozen Eleven | Frozen Eleven"-    static let entryARawURL = "https://frozen11.example/read?series=99&chapter=11"+    static let entryACaptureTitle = "Read: Chapter 11 — A Frozen Twelve | Frozen Twelve"+    static let entryARawURL = "https://frozen12.example/read?series=99&chapter=11" -    static let entryACanonicalURL = "https://frozen11.example/read?chapter=11&series=99"+    static let entryACanonicalURL = "https://frozen12.example/read?chapter=11&series=99" -    static let entryBNote = "Recorded at 11.0.0, name-keyed"+    static let entryBNote = "Recorded at 12.0.0, name-keyed"     static let entryBSequence = "12"     static let entryBChapterTitle = "Chapter 12"-    static let entryBCaptureTitle = "Read: Chapter 12 — A Frozen Eleven | Frozen Eleven"-    static let entryBRawURL = "https://frozen11.example/read?series=99&chapter=12"+    static let entryBCaptureTitle = "Read: Chapter 12 — A Frozen Twelve | Frozen Twelve"+    static let entryBRawURL = "https://frozen12.example/read?series=99&chapter=12"      /// Entry C is the nil-blob row: captured through the share extension by a     /// build that predates the blob and never rewritten, so its `citationsData`     /// is still absent. It reads as the default `EntryCitations`, which is why     /// its identity fields are the conservative, unassigned tuple.     static let entryCNote = "Captured before the citation blob existed"-    static let entryCCaptureTitle = "Read: Chapter 13 — A Frozen Eleven | Frozen Eleven"-    static let entryCRawURL = "https://frozen11.example/read?series=99&chapter=13"+    static let entryCCaptureTitle = "Read: Chapter 13 — A Frozen Twelve | Frozen Twelve"+    static let entryCRawURL = "https://frozen12.example/read?series=99&chapter=13"      /// The fact the seeded character carries, cited from Entry A — so the     /// `factsData` blob is genuinely populated rather than an empty array.-    static var characterFact: CharacterFact {-        CharacterFact(-            statement: "Eleven is the narrator.", quote: "I am Eleven.",+    static var characterFact: RecordFact {+        RecordFact(+            statement: "Twelve is the narrator.", quote: "I am Twelve.",             nameKey: characterNameKey, source: .entry(entryAID))     } @@ -258,9 +270,9 @@ enum V11RecordedStoreFixture {             workAssignment: .pattern(citedPattern))     } -    /// Opens a container over the frozen V11 snapshot at `storeURL`, hands its+    /// Opens a container over the frozen V12 snapshot at `storeURL`, hands its     /// context to `seed`, saves, and releases the container so the file on disk-    /// is a closed store recorded at 11.0.0.+    /// is a closed store recorded at 12.0.0.     ///     /// **The order here is the safety property**, not the schema — see the type's     /// doc comment. The snapshot container is released before this returns, so no@@ -268,7 +280,7 @@ enum V11RecordedStoreFixture {     static func write(at storeURL: URL, seed: (ModelContext) throws -> Void) throws {         try FileManager.default.createDirectory(             at: storeURL.deletingLastPathComponent(), withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV11.self)+        let schema = Schema(versionedSchema: AsterismSchemaV12.self)         let configuration = ModelConfiguration(             // The same store-configuration name `openContainer` uses; a mismatch             // here would make the reopen create a second store.@@ -287,7 +299,7 @@ enum V11RecordedStoreFixture {         guard case .success(let parsed) = TitleRuleApplicator.apply(             definition: patternDefinition, trimPrefix: trimPrefix, trimSuffix: trimSuffix,             to: captureTitle) else {-            throw ModelInvariantError.invalidCombination(field: "V11 fixture title replay")+            throw ModelInvariantError.invalidCombination(field: "V12 fixture title replay")         }         return parsed.workName     }@@ -319,7 +331,7 @@ enum V11RecordedStoreFixture {         let v3Key = try entryBIdentityKey          try write(at: storeURL) { context in-            let site = AsterismSchemaV11.Site()+            let site = AsterismSchemaV12.Site()             site.hostname = hostname             site.displayName = siteDisplayName             site.modeRaw = SiteMode.taught.rawValue@@ -327,7 +339,7 @@ enum V11RecordedStoreFixture {             context.insert(site)              // The phrase arm, in the blob that has been its only home since V9.-            let pattern = AsterismSchemaV11.TitlePattern()+            let pattern = AsterismSchemaV12.TitlePattern()             pattern.id = patternID             pattern.version = patternVersion             pattern.isActive = true@@ -338,7 +350,7 @@ enum V11RecordedStoreFixture {              // The retired segment arm. Inactive: a taught Site holds exactly one             // active title rule.-            let segmentPattern = AsterismSchemaV11.TitlePattern()+            let segmentPattern = AsterismSchemaV12.TitlePattern()             segmentPattern.id = segmentPatternID             segmentPattern.version = segmentPatternVersion             segmentPattern.isActive = false@@ -347,7 +359,7 @@ enum V11RecordedStoreFixture {             context.insert(segmentPattern)             segmentPattern.site = site -            let rule = AsterismSchemaV11.URLRulePattern()+            let rule = AsterismSchemaV12.URLRulePattern()             rule.id = urlRuleID             rule.version = urlRuleVersion             rule.isCurrent = true@@ -359,7 +371,7 @@ enum V11RecordedStoreFixture {             context.insert(rule)             rule.site = site -            let type = AsterismSchemaV11.WorkTypeEntity()+            let type = AsterismSchemaV12.WorkTypeEntity()             type.id = workTypeID             type.name = workTypeName             type.stateRaw = WorkTypeState.active.rawValue@@ -369,15 +381,15 @@ enum V11RecordedStoreFixture {             type.stateModifiedAt = timestamp             context.insert(type) -            // **No creator, role or credit row is seeded, because V11 has no+            // **No place or place-suppression row is seeded, because V12 has no             // table for one** — that is what this fixture exists to say, and-            // `V11RecordedStoreTests` asserts it by their emptiness on the far+            // `V12RecordedStoreTests` asserts it by their emptiness on the far             // side of the stage.             //-            // Every column V10 and V11 *do* have is seeded away from its+            // Every column V10, V11 and V12 *do* have is seeded away from its             // default, so a conversion that re-applied one over an existing row             // would show up rather than read as a pass.-            let work = AsterismSchemaV11.Work()+            let work = AsterismSchemaV12.Work()             work.id = workID             work.displayTitle = workName             work.lastParsedTitle = workName@@ -398,7 +410,7 @@ enum V11RecordedStoreFixture {             // The Work's site presence: since V9 the membership row is the only             // home it has. It cites its identity rule by UUID alone (Req 10.4,             // Q28 of `multi-site-works`), which is why no version travels with it.-            let membership = AsterismSchemaV11.WorkSiteMembership()+            let membership = AsterismSchemaV12.WorkSiteMembership()             membership.id = membershipID             membership.hostname = hostname             membership.createdAt = timestamp@@ -412,7 +424,7 @@ enum V11RecordedStoreFixture {             membership.site = site              // Entry A: the v2 identity arm and URL-rule work assignment.-            let entryA = AsterismSchemaV11.Entry()+            let entryA = AsterismSchemaV12.Entry()             entryA.id = entryAID             entryA.captureTitle = entryACaptureTitle             entryA.captureTitleSourceRaw = CaptureTitleSource.host.rawValue@@ -437,7 +449,7 @@ enum V11RecordedStoreFixture {             entryA.site = site              // Entry B: the v3 identity arm and the pattern work assignment.-            let entryB = AsterismSchemaV11.Entry()+            let entryB = AsterismSchemaV12.Entry()             entryB.id = entryBID             entryB.captureTitle = entryBCaptureTitle             entryB.captureTitleSourceRaw = CaptureTitleSource.host.rawValue@@ -460,7 +472,7 @@ enum V11RecordedStoreFixture {             // Entry C: **no citation blob**. It keeps its Work link — the             // assignment provenance is what a V8 build never wrote, the             // relationship is not (Q25 of `drop-superseded-columns`).-            let entryC = AsterismSchemaV11.Entry()+            let entryC = AsterismSchemaV12.Entry()             entryC.id = entryCID             entryC.captureTitle = entryCCaptureTitle             entryC.captureTitleSourceRaw = CaptureTitleSource.host.rawValue@@ -483,17 +495,16 @@ enum V11RecordedStoreFixture {             // assert it: a stage that lost one would surface as duplicate Works             // the reader has already told the app are distinct.             let pairIDs = WorkDistinctPair.sortedIDs(workID, distinctPairOtherWorkID)-            let pair = AsterismSchemaV11.WorkDistinctPair()+            let pair = AsterismSchemaV12.WorkDistinctPair()             pair.id = distinctPairID             pair.lowerWorkID = pairIDs.lower             pair.higherWorkID = pairIDs.higher             pair.recordedAt = timestamp             context.insert(pair) -            // V11's two tables, seeded so the stage has something to carry-            // across rather than two more empty tables that would pass either-            // way. The series row is the one `Work.seriesID` names.-            let series = AsterismSchemaV11.Series()+            // V11's two tables, carried forward from the retired fixture. The+            // series row is the one `Work.seriesID` names.+            let series = AsterismSchemaV12.Series()             series.id = seriesID             series.name = seriesName             series.notes = seriesNotes@@ -502,7 +513,7 @@ enum V11RecordedStoreFixture {             context.insert(series)              let linkIDs = WorkDistinctPair.sortedIDs(workID, linkOtherWorkID)-            let link = AsterismSchemaV11.WorkLink()+            let link = AsterismSchemaV12.WorkLink()             link.id = linkID             link.lowerWorkID = linkIDs.lower             link.higherWorkID = linkIDs.higher@@ -511,19 +522,55 @@ enum V11RecordedStoreFixture {             link.modifiedAt = timestamp             context.insert(link) -            let character = AsterismSchemaV11.Character()+            // V12's three tables, seeded so the stage has something to carry+            // across rather than three more empty tables that would pass either+            // way. The role is a fourth beside the three the app seeds.+            let creator = AsterismSchemaV12.Creator()+            creator.id = creatorID+            creator.name = creatorName+            creator.notes = creatorNotes+            creator.stateRaw = "active"+            creator.createdAt = timestamp+            creator.modifiedAt = timestamp+            creator.nameModifiedAt = timestamp+            creator.notesModifiedAt = timestamp+            creator.stateModifiedAt = timestamp+            context.insert(creator)++            let role = AsterismSchemaV12.CreatorRole()+            role.id = creatorRoleID+            role.name = creatorRoleName+            role.position = creatorRolePosition+            role.stateRaw = "active"+            role.createdAt = timestamp+            role.modifiedAt = timestamp+            role.nameModifiedAt = timestamp+            role.positionModifiedAt = timestamp+            role.stateModifiedAt = timestamp+            context.insert(role)++            let credit = AsterismSchemaV12.WorkCredit()+            credit.id = creditID+            credit.workID = workID+            credit.creatorID = creatorID+            credit.roleIDs = [creatorRoleID.uuidString]+            credit.createdAt = timestamp+            credit.modifiedAt = timestamp+            context.insert(credit)++            let character = AsterismSchemaV12.Character()             character.id = characterID             character.name = characterName             character.nameKey = characterNameKey             character.aliases = characterAliases             character.note = characterNote-            character.factsData = CharacterFactCodec.encode([characterFact])+            character.factsData = RecordFactCodec.encode([characterFact])             character.createdAt = timestamp             character.modifiedAt = timestamp             context.insert(character)             character.work = work -            let suppression = AsterismSchemaV11.CharacterSuppression()+            let suppression = AsterismSchemaV12.CharacterSuppression()             suppression.id = suppressionID             suppression.kindRaw = CharacterSuppressionKind.candidate.rawValue             suppression.nameKey = suppressionNameKey
Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swift Renamed +127 / -63
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swiftsimilarity index 81%rename from Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreTests.swiftrename to Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swiftindex fa2a40b..e1d44cd 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V12RecordedStoreTests.swift@@ -4,44 +4,44 @@ import Testing  @testable import AsterismCore -/// The V11 → V12 conversion, over a store genuinely **recorded at 11.0.0**.+/// The V12 → V13 conversion, over a store genuinely **recorded at 12.0.0**. /// /// This is the path every installed library takes on the update that ships-/// `work-creators`: the store on disk was written by the V11 classes, and+/// `place-extraction`: the store on disk was written by the V12 classes, and /// `ModelContainer.init` runs the plan's one lightweight stage on the way in.-/// Every other store a test builds is born at 12.0.0, so a regression here would+/// Every other store a test builds is born at 13.0.0, so a regression here would /// otherwise only be visible on the owner's phone. ///-/// **This stage adds, and adds only tables** — the first in the project's+/// **This stage adds, and adds only tables** — the second in the project's /// history to do so. There is no new column anywhere, so there is not even an /// attribute default to write: the whole of the conversion is the store coming-/// out with three more tables holding nothing (Req 11.1).+/// out with two more tables holding nothing (Req 5.6). /// /// The assertions are therefore in two halves. The first is that the addition /// landed and landed empty, read through raw fetches. The second is that nothing /// else moved: the whole live library, field by field, exactly as the retired-/// `V10RecordedStoreTests` asserted it one generation back — the three status-/// columns and V11's own series columns, tables and rows included, all of which-/// this fixture seeds away from their defaults precisely so a re-applied default-/// would show.-@Suite("An 11.0.0-recorded store under the V12 plan", .serialized)-struct V11RecordedStoreTests {+/// `V11RecordedStoreTests` asserted it one generation back — the status columns,+/// the series columns, and V12's own creator, role and credit rows included, all+/// of which this fixture seeds away from their defaults precisely so a+/// re-applied default would show.+@Suite("A 12.0.0-recorded store under the V13 plan", .serialized)+struct V12RecordedStoreTests { -    private typealias Fixture = V11RecordedStoreFixture+    private typealias Fixture = V12RecordedStoreFixture -    /// A library exactly as a V11 build leaves it: the store recorded at-    /// 11.0.0 with no creator, role or credit table, and the marker at `"11"`.+    /// A library exactly as a V12 build leaves it: the store recorded at+    /// 12.0.0 with no place or place-suppression table, and the marker at `"12"`.     private final class Root {         let url: URL         let configuration: LibraryConfiguration          init() throws {             url = FileManager.default.temporaryDirectory.appending(-                path: "V11Recorded-\(UUID())", directoryHint: .isDirectory)+                path: "V12Recorded-\(UUID())", directoryHint: .isDirectory)             try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)             configuration = LibraryConfiguration(rootDirectory: url)             try Fixture.install(at: configuration.storeURL)-            try Data("11\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)+            try Data("12\n".utf8).write(to: configuration.readinessMarkerURL, options: .atomic)         }          deinit { try? FileManager.default.removeItem(at: url) }@@ -56,20 +56,20 @@ struct V11RecordedStoreTests {         }     } -    @Test("The seeded store really is recorded at 11.0.0, on the lagging marker")-    func seedIsRecordedAtElevenZeroZero() throws {+    @Test("The seeded store really is recorded at 12.0.0, on the lagging marker")+    func seedIsRecordedAtTwelveZeroZero() throws {         let root = try Root()-        #expect(try root.recordedVersions() == ["11.0.0"])-        #expect(try root.markerText() == "11")+        #expect(try root.recordedVersions() == ["12.0.0"])+        #expect(try root.markerText() == "12")         #expect(try LibraryRepository.classify(root.configuration, fileManager: .default)-                == .markerLagging(generation: "11"))+                == .markerLagging(generation: "12"))         withExtendedLifetime(root) {}     }      /// The whole of it: `openForApp` runs the stage, validates, publishes-    /// `"12"`, and every live row is still there afterwards with its value —-    /// plus three empty tables.-    @Test("openForApp converts to 12.0.0, adding three empty tables")+    /// `"13"`, and every live row is still there afterwards with its value —+    /// plus two empty tables.+    @Test("openForApp converts to 13.0.0, adding two empty tables")     func convertsWithEveryLiveRowIntact() async throws {         let root = try Root()         let (result, repository) = try await LibraryRepository.openForApp(root.configuration)@@ -81,8 +81,8 @@ struct V11RecordedStoreTests {             return         }         // The stage completed and the marker moved, in that order.-        #expect(try root.recordedVersions() == ["12.0.0"])-        #expect(try root.markerText() == "12")+        #expect(try root.recordedVersions() == ["13.0.0"])+        #expect(try root.markerText() == "13")         #expect(counts.works == 1)         #expect(counts.entries == 3)         #expect(counts.sites == 1)@@ -91,27 +91,15 @@ struct V11RecordedStoreTests {          let facts = try await repository.withLockedContext(             mode: .shared, operation: "reading the converted library"-        ) { context in try ConvertedV12Library(context: context) }+        ) { context in try ConvertedV13Library(context: context) }         await repository.shutdown()          // MARK: what the stage added — the point of the whole suite         //-        // Three tables, and nothing else. V11 had no row to put in any of them,-        // so the conversion itself brings across nothing; the three roles are-        // `seedCreatorRoles`, which runs in `openForApp` beside `seedWorkTypes`-        // once the store is open (Req 11.1, Q24). A seeded role is not a reader-        // record, which is why the counts above still read as an unchanged-        // library.-        #expect(facts.creatorCount == 0)-        #expect(facts.creatorRoleCount == CreatorRoleSeeding.seeds.count)-        #expect(facts.creditCount == 0)-        #expect(-            facts.creatorRoleIDs == CreatorRoleSeeding.seeds.map(\.id).sorted {-                $0.uuidString < $1.uuidString-            },-            // The literal identifiers behind the seeds are pinned by-            // `specs/retire-migration-chain/library-graph-baseline.txt`, not here.-            "only these three rows are in the table, and nothing else moved")+        // Two tables, and nothing else. V12 had no row to put in either, so the+        // conversion itself brings across nothing, and no writer has run since.+        #expect(facts.placeCount == 0)+        #expect(facts.placeSuppressionCount == 0)          // MARK: what the *previous* stages supplied, which this one may not         // touch. The fixture seeds these away from their defaults on purpose:@@ -146,6 +134,35 @@ struct V11RecordedStoreTests {         #expect(link.createdAt == Fixture.timestamp)         #expect(link.modifiedAt == Fixture.timestamp) +        // MARK: V12's three tables, which ride through untouched too. The role+        // the fixture seeds is a *fourth* beside the three `seedCreatorRoles`+        // mints on every app open (Req 11.1, Q24 of `work-creators`), so a+        // conversion that lost it would show as a count of three.+        #expect(facts.creators.count == 1)+        let creator = try #require(facts.creators.first)+        #expect(creator.id == Fixture.creatorID)+        #expect(creator.name == Fixture.creatorName)+        #expect(creator.notes == Fixture.creatorNotes)+        #expect(creator.stateRaw == "active")+        #expect(creator.createdAt == Fixture.timestamp)+        #expect(creator.modifiedAt == Fixture.timestamp)++        #expect(facts.creatorRoleCount == CreatorRoleSeeding.seeds.count + 1)+        let role = try #require(facts.creatorRoles.first { $0.id == Fixture.creatorRoleID })+        #expect(role.name == Fixture.creatorRoleName)+        #expect(role.position == Fixture.creatorRolePosition)+        #expect(role.stateRaw == "active")+        #expect(role.modifiedAt == Fixture.timestamp)++        #expect(facts.credits.count == 1)+        let credit = try #require(facts.credits.first)+        #expect(credit.id == Fixture.creditID)+        #expect(credit.workID == Fixture.workID)+        #expect(credit.creatorID == Fixture.creatorID)+        #expect(credit.roleIDs == [Fixture.creatorRoleID.uuidString])+        #expect(credit.createdAt == Fixture.timestamp)+        #expect(credit.modifiedAt == Fixture.timestamp)+         // MARK: the Site         #expect(facts.siteHostnames == [Fixture.hostname])         #expect(facts.siteDisplayName == Fixture.siteDisplayName)@@ -308,9 +325,9 @@ struct V11RecordedStoreTests {     }      /// The converted graph is one the validator accepts, with no hostname-    /// quarantined. Nothing about the three new tables is validated — an-    /// unresolved credit is data, not damage (Req 10.2) — so what this pins is-    /// that the stage left a library that is still legal.+    /// quarantined. Nothing about the two new tables is validated — an+    /// unresolved place `workID` is data, not damage (Req 5.5) — so what this+    /// pins is that the stage left a library that is still legal.     @Test("The converted library validates with nothing quarantined")     func convertedLibraryValidates() async throws {         let root = try Root()@@ -324,8 +341,8 @@ struct V11RecordedStoreTests {         withExtendedLifetime(root) {}     } -    /// The extension is what the marker keeps out of the stage (Req 11.3): it-    /// refuses `"11"`, and opens the same library once the app has moved it.+    /// The extension is what the marker keeps out of the stage (Req 5.6): it+    /// refuses `"12"`, and opens the same library once the app has moved it.     @Test("The extension refuses the store until the app has converted it")     func extensionOpensOnlyAfterTheApp() async throws {         let root = try Root()@@ -335,7 +352,7 @@ struct V11RecordedStoreTests {             reason: "Open Asterism to finish updating the library")) {             try await LibraryRepository.openForExtension(root.configuration)         }-        #expect(try root.recordedVersions() == ["11.0.0"],+        #expect(try root.recordedVersions() == ["12.0.0"],                 "the refusal has to land before ModelContainer.init converts the store")          let (_, app) = try await LibraryRepository.openForApp(root.configuration)@@ -362,8 +379,8 @@ struct V11RecordedStoreTests {         #expect(try LibraryRepository.classify(root.configuration, fileManager: .default) == .ready)         let (_, second) = try await LibraryRepository.openForApp(root.configuration)         await second.shutdown()-        #expect(try root.markerText() == "12")-        #expect(try root.recordedVersions() == ["12.0.0"])+        #expect(try root.markerText() == "13")+        #expect(try root.recordedVersions() == ["13.0.0"])         withExtendedLifetime(root) {}     } }@@ -373,7 +390,7 @@ struct V11RecordedStoreTests { /// Everything the converted store holds, read once inside the locked context. /// Model classes are not `Sendable` and may not leave the actor, so the whole /// comparison is done against copies.-private struct ConvertedV12Library: Sendable {+private struct ConvertedV13Library: Sendable {     struct EntryFacts: Sendable {         let captureTitle: String         let captureTitleSource: CaptureTitleSource@@ -429,6 +446,32 @@ private struct ConvertedV12Library: Sendable {         let modifiedAt: Date     } +    struct CreatorFacts: Sendable, Equatable {+        let id: UUID+        let name: String+        let notes: String+        let stateRaw: String+        let createdAt: Date+        let modifiedAt: Date+    }++    struct CreatorRoleFacts: Sendable, Equatable {+        let id: UUID+        let name: String+        let position: Int+        let stateRaw: String+        let modifiedAt: Date+    }++    struct CreditFacts: Sendable, Equatable {+        let id: UUID+        let workID: UUID+        let creatorID: UUID+        let roleIDs: [String]+        let createdAt: Date+        let modifiedAt: Date+    }+     struct MembershipFacts: Sendable {         let id: UUID         let hostname: String@@ -476,14 +519,15 @@ private struct ConvertedV12Library: Sendable {     let seriesPosition: Double?     let series: [SeriesFacts]     let links: [LinkFacts]-    /// V12's three tables. The stage itself produces no row of any of them; the-    /// roles are the app's seeding, running on the converted store.-    let creatorCount: Int+    /// V12's three tables, seeded for the same reason.+    let creators: [CreatorFacts]+    let creatorRoles: [CreatorRoleFacts]     let creatorRoleCount: Int-    let creditCount: Int-    /// Sorted, so "only the three seeded roles" is an identity comparison-    /// rather than a count.-    let creatorRoleIDs: [UUID]+    let credits: [CreditFacts]+    /// V13's two tables. The stage itself produces no row of either, and no+    /// writer has run since.+    let placeCount: Int+    let placeSuppressionCount: Int      let memberships: [MembershipFacts]     let entries: [UUID: EntryFacts]@@ -496,7 +540,7 @@ private struct ConvertedV12Library: Sendable {     let characterNameKey: String     let characterAliases: [String]     let characterNote: String-    let characterFacts: [CharacterFact]+    let characterFacts: [RecordFact]     let characterWorkID: UUID?      let suppressionID: UUID@@ -563,11 +607,31 @@ private struct ConvertedV12Library: Sendable {                     id: $0.id, lowerWorkID: $0.lowerWorkID, higherWorkID: $0.higherWorkID,                     linkType: $0.linkType, createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)             }-        creatorCount = try context.fetch(FetchDescriptor<Creator>()).count+        creators = try context.fetch(FetchDescriptor<Creator>())+            .sorted { $0.id.uuidString < $1.id.uuidString }+            .map {+                CreatorFacts(+                    id: $0.id, name: $0.name, notes: $0.notes, stateRaw: $0.stateRaw,+                    createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)+            }         let roleRows = try context.fetch(FetchDescriptor<CreatorRole>())         creatorRoleCount = roleRows.count-        creatorRoleIDs = roleRows.map(\.id).sorted { $0.uuidString < $1.uuidString }-        creditCount = try context.fetch(FetchDescriptor<WorkCredit>()).count+        creatorRoles = roleRows+            .sorted { $0.id.uuidString < $1.id.uuidString }+            .map {+                CreatorRoleFacts(+                    id: $0.id, name: $0.name, position: $0.position, stateRaw: $0.stateRaw,+                    modifiedAt: $0.modifiedAt)+            }+        credits = try context.fetch(FetchDescriptor<WorkCredit>())+            .sorted { $0.id.uuidString < $1.id.uuidString }+            .map {+                CreditFacts(+                    id: $0.id, workID: $0.workID, creatorID: $0.creatorID, roleIDs: $0.roleIDs,+                    createdAt: $0.createdAt, modifiedAt: $0.modifiedAt)+            }+        placeCount = try context.fetch(FetchDescriptor<Place>()).count+        placeSuppressionCount = try context.fetch(FetchDescriptor<PlaceSuppression>()).count          memberships = try context.fetch(FetchDescriptor<WorkSiteMembership>())             .sorted { $0.id.uuidString < $1.id.uuidString }
Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift Modified +13 / -13
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swiftindex 3b68e4a..40a6c07 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/V4RecordedStoreTests.swift@@ -7,16 +7,16 @@ import Testing  /// The one store in the repository actually **recorded at 4.0.0**. ///-/// Nothing on this branch can write one any more: the live classes are V12's, so-/// every store a test creates today is recorded at 12.0.0 (or at 11.0.0, through-/// the frozen snapshot — see `V11RecordedStoreFixture`). It is the only input+/// Nothing on this branch can write one any more: the live classes are V13's, so+/// every store a test creates today is recorded at 13.0.0 (or at 12.0.0, through+/// the frozen snapshot — see `V12RecordedStoreFixture`). It is the only input /// that positively reads *below* V5, which is what `StoreMetadataTests`, /// `BootstrapClassifierTests` and `BootstrapActionTests` need it for. /// /// It is no longer a *convertible* store: since the plan declares real stages, a /// model version the plan does not declare is refused rather than raised-/// implicitly. Under `AsterismV12MigrationPlan` = `[V11, V12]` the floor has-/// risen six versions since that first became true, so 4.0.0 is refused with+/// implicitly. Under `AsterismV13MigrationPlan` = `[V12, V13]` the floor has+/// risen seven versions since that first became true, so 4.0.0 is refused with /// more room to spare than ever. What can still be asserted about it is the /// refusal. ///@@ -56,8 +56,8 @@ enum V4RecordedStoreFixture {      /// The schema versions Core Data recorded into the store's own metadata —     /// `["4.0.0"]` for this fixture (nothing converts a 4.0.0 store any more;-    /// `V11RecordedStoreTests` uses this helper to observe `"11.0.0"` before the-    /// stage and `"12.0.0"` after it).+    /// `V12RecordedStoreTests` uses this helper to observe `"12.0.0"` before the+    /// stage and `"13.0.0"` after it).     /// Read straight out of `Z_METADATA` rather than through SwiftData, so     /// asking the question cannot itself perform the conversion.     static func recordedModelVersions(at storeURL: URL) throws -> [String] {@@ -106,17 +106,17 @@ enum V4RecordedStoreFixture { /// stage ended that — a staged migration refuses a model version the plan does /// not declare — so the conversion those tests asserted, and the 5.0.0 end state /// they pinned, no longer exist to assert. The 432-Entry scale fixture went with-/// them: nothing can open it. `AsterismV12MigrationPlan` is `[V11, V12]`, so the-/// floor has since risen six more versions and 4.0.0 is refused by a wider+/// them: nothing can open it. `AsterismV13MigrationPlan` is `[V12, V13]`, so the+/// floor has since risen seven more versions and 4.0.0 is refused by a wider /// margin than when this suite was written. /// /// Nothing shipped changes. `classify` already refuses a below-V5 store before /// any container is constructed (Req 2.9, Decision 1 of /// `retire-migration-chain`), and the recovery for one remains the 4/4 backup-/// archive. The conversion coverage is `V11RecordedStoreTests`, which seeds its-/// input through the frozen V11 snapshot — the version installed libraries-/// actually hold, and under `[V11, V12]` the only one that converts at all.-@Suite("A 4.0.0-recorded store under the V12 plan", .serialized)+/// archive. The conversion coverage is `V12RecordedStoreTests`, which seeds its+/// input through the frozen V12 snapshot — the version installed libraries+/// actually hold, and under `[V12, V13]` the only one that converts at all.+@Suite("A 4.0.0-recorded store under the V13 plan", .serialized) struct V4RecordedStoreTests {      private final class TempDir {
Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift Modified +205 / -16
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swiftindex bc0e699..b995c05 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift@@ -76,16 +76,16 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Hanna", nameKey: "hanna",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Stays behind", quote: "Hanna stays behind",                             nameKey: "hanna", source: .entry(sameAsWork)),-                        CharacterFact(+                        RecordFact(                             statement: "Rows the tender", quote: "Hanna rows the tender",                             nameKey: "hanna", source: .entry(chaptered)),-                        CharacterFact(+                        RecordFact(                             statement: "Numbers the days", quote: "Hanna numbers the days",                             nameKey: "hanna", source: .entry(sequenceOnly)),-                        CharacterFact(+                        RecordFact(                             statement: "Keeps the lamp", quote: "Hanna keeps the lamp",                             nameKey: "hanna", source: .entry(distinct)),                     ],@@ -270,18 +270,18 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Hanna", nameKey: "hanna",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Rows the tender", quote: "Hanna rows the tender",                             nameKey: "hanna", source: .entry(numbered)),-                        CharacterFact(+                        RecordFact(                             statement: "Keeps the lamp", quote: "Hanna keeps the lamp",                             nameKey: "hanna", source: .entry(unnumbered)),                         // The cited note is not this work's — Req 3.5's                         // tolerated dangling citation.-                        CharacterFact(+                        RecordFact(                             statement: "Stays behind", quote: "Hanna stays behind",                             nameKey: "hanna", source: .entry(gone)),-                        CharacterFact(+                        RecordFact(                             statement: "Sails at dawn", quote: "Hanna sails at dawn",                             nameKey: "hanna", source: .genericNotes),                     ],@@ -355,10 +355,10 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Zara", nameKey: "zara",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Sails at dawn", quote: "Zara sails at dawn",                             nameKey: "zara", source: .entry(chapterOne)),-                        CharacterFact(+                        RecordFact(                             statement: "Keeps the lamp", quote: "Zara keeps the lamp",                             nameKey: "zara", source: .entry(chapterTwo)),                     ],@@ -368,7 +368,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Bram", nameKey: "bram",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Carries the rope", quote: "Bram carries the rope",                             nameKey: "bram", source: .entry(chapterTwo))                     ],@@ -376,7 +376,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Ana", nameKey: "ana",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Waits on the pier", quote: "Ana waits on the pier",                             nameKey: "ana", source: .entry(chapterTwo))                     ],@@ -436,7 +436,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Ada", nameKey: "ada",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Rows the tender", quote: "Ada rows the tender",                             nameKey: "ada", source: .entry(duplicated))                     ],@@ -444,7 +444,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Zed", nameKey: "zed",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Waits on the pier", quote: "Zed waits on the pier",                             nameKey: "zed", source: .entry(chapterTwo))                     ],@@ -486,7 +486,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Ana", nameKey: "ana",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Stays behind", quote: "Ana stays behind",                             nameKey: "ana", source: .entry(early))                     ],@@ -495,7 +495,7 @@ struct WorkDetailReadTests {                 M5SeedCharacter(                     id: UUID(), name: "Zara", nameKey: "zara",                     facts: [-                        CharacterFact(+                        RecordFact(                             statement: "Sails at dawn", quote: "Zara sails at dawn",                             nameKey: "zara", source: .entry(later))                     ],@@ -511,6 +511,193 @@ struct WorkDetailReadTests {         #expect(detail.characters.map(\.name) == ["Zara", "Ana"])     } +    // MARK: - Places (`place-extraction` Reqs 4.1–4.3)++    /// Req 4.3: the `character-ranking` rule computed over the work's **places+    /// alone**, with the same half-life and fact function, name order for ties+    /// and for places nothing is noted about.+    ///+    /// The character seeded beside them would outrank every place if the two+    /// lists were scored together, and it is not in the place list at all — the+    /// two collections are ranked independently (Q14).+    @Test("Places rank over the work's places alone, name order among equals (4.3)")+    func placesAreRankedByProminence() async throws {+        let fixture = try await M5Fixture()+        let workID = UUID()+        let chapterOne = UUID()+        let chapterTwo = UUID()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "example.com")],+            works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+            entries: [+                M5SeedEntry(+                    id: chapterOne, captureTitle: "A Serial", hostname: "example.com", path: "one",+                    chapterSequence: "1", workID: workID),+                M5SeedEntry(+                    id: chapterTwo, captureTitle: "A Serial", hostname: "example.com", path: "two",+                    chapterSequence: "2", workID: workID),+            ],+            characters: [+                M5SeedCharacter(+                    id: UUID(), name: "Zara", nameKey: "zara",+                    facts: [+                        RecordFact(+                            statement: "Sails at dawn", quote: "Zara sails at dawn",+                            nameKey: "zara", source: .entry(chapterOne)),+                        RecordFact(+                            statement: "Keeps the lamp", quote: "Zara keeps the lamp",+                            nameKey: "zara", source: .entry(chapterTwo)),+                    ],+                    workID: workID)+            ],+            places: [+                // Noted in both chapters — two buckets, so breadth as well as+                // count puts it first however the tie-breaks fall.+                M5SeedPlace(+                    id: UUID(), name: "Ward Bay", nameKey: "ward bay",+                    facts: [+                        RecordFact(+                            statement: "The fleet anchors", quote: "in Ward Bay",+                            nameKey: "ward bay", source: .entry(chapterOne)),+                        RecordFact(+                            statement: "The storm lands", quote: "over Ward Bay",+                            nameKey: "ward bay", source: .entry(chapterTwo)),+                    ],+                    workID: workID),+                // Two walk-ons with the same single-fact profile in the same+                // chapter: equal scores, so name order decides between them.+                M5SeedPlace(+                    id: UUID(), name: "Bram Rock", nameKey: "bram rock",+                    facts: [+                        RecordFact(+                            statement: "Marks the channel", quote: "past Bram Rock",+                            nameKey: "bram rock", source: .entry(chapterTwo))+                    ],+                    workID: workID),+                M5SeedPlace(+                    id: UUID(), name: "Ana Point", nameKey: "ana point",+                    facts: [+                        RecordFact(+                            statement: "Holds the light", quote: "on Ana Point",+                            nameKey: "ana point", source: .entry(chapterTwo))+                    ],+                    workID: workID),+                // Nothing noted about it at all: below every scored place,+                // whatever its name would say (Req 4.3).+                M5SeedPlace(id: UUID(), name: "Cora Cove", nameKey: "cora cove", workID: workID),+            ])++        let detail = try await fixture.repository.workDetail(id: workID)+        #expect(detail.places.map(\.name) == ["Ward Bay", "Ana Point", "Bram Rock", "Cora Cove"])+        #expect(detail.places.allSatisfy { $0.kind == .place },+                "each presentation names the collection it belongs to")+        #expect(detail.characters.map(\.name) == ["Zara"],+                "the character list is unchanged by the places beside it")+    }++    /// Req 4.2: the same fact derivation the characters get — capture order,+    /// resolved citations, a dangling one displayed without navigation.+    @Test("A place's facts carry their citations in capture order (4.2)")+    func placeFactsCarryCitations() async throws {+        let fixture = try await M5Fixture()+        let workID = UUID()+        let chapterOne = UUID()+        let chapterTwo = UUID()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "example.com")],+            works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+            entries: [+                M5SeedEntry(+                    id: chapterOne, captureTitle: "Chapter One", hostname: "example.com",+                    path: "one", chapterTitle: "Chapter One",+                    firstCapturedAt: M5Fixture.epoch,+                    lastSharedAt: M5Fixture.epoch, workID: workID),+                M5SeedEntry(+                    id: chapterTwo, captureTitle: "Chapter Two", hostname: "example.com",+                    path: "two", chapterTitle: "Chapter Two",+                    firstCapturedAt: M5Fixture.epoch.addingTimeInterval(60),+                    lastSharedAt: M5Fixture.epoch.addingTimeInterval(60), workID: workID),+            ],+            places: [+                M5SeedPlace(+                    id: UUID(), name: "Ward Bay", nameKey: "ward bay",+                    facts: [+                        RecordFact(+                            statement: "The storm lands", quote: "over Ward Bay",+                            nameKey: "ward bay", source: .entry(chapterTwo)),+                        RecordFact(+                            statement: "The fleet anchors", quote: "in Ward Bay",+                            nameKey: "ward bay", source: .entry(chapterOne)),+                        RecordFact(+                            statement: "Named for the ward", quote: "the ward",+                            nameKey: "ward bay", source: .entry(UUID())),+                    ],+                    workID: workID)+            ])++        let place = try #require(try await fixture.repository.workDetail(id: workID).places.first)+        #expect(place.facts.map(\.statement)+                == ["The fleet anchors", "The storm lands", "Named for the ward"],+                "capture order, with the dangling citation last (Q88)")+        #expect(place.facts.map(\.citationTitle)+                == ["Chapter One", "Chapter Two", nil])+        #expect(place.facts.map(\.isDangling) == [false, false, true])+        #expect(place.facts.last?.citedEntryID == nil, "Req 3.6: it displays, it does not navigate")+    }++    /// One fetch answers the whole page (`swiftdata-relationships.md` rule 2):+    /// `Place.rows(of:)` is a single predicate over the work group's ids, so+    /// what a page shows is exactly the rows naming this work. Another work's+    /// places and an orphan's are invisible to it — the orphan permanently, and+    /// without being an error (Req 5.5).+    @Test("A work's places are its own; another work's and an orphan's never appear (4.1)")+    func placesAreScopedToTheWork() async throws {+        let fixture = try await M5Fixture()+        let workID = UUID()+        let otherWorkID = UUID()+        let mine = UUID()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "example.com")],+            works: [+                M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com"),+                M5SeedWork(id: otherWorkID, displayTitle: "Another", hostname: "example.com"),+            ],+            places: [+                M5SeedPlace(id: mine, name: "Ward Bay", nameKey: "ward bay", workID: workID),+                M5SeedPlace(+                    id: UUID(), name: "Elsewhere", nameKey: "elsewhere", workID: otherWorkID),+                // A work id that resolves to nothing: the tolerated orphan.+                M5SeedPlace(id: UUID(), name: "Nowhere", nameKey: "nowhere", workID: UUID()),+            ])++        let detail = try await fixture.repository.workDetail(id: workID)+        #expect(detail.places.map(\.id) == [mine])+        #expect(try await fixture.repository.m5AllPlaces().count == 3,+                "the other two rows are still there; they are just not this page's")+    }++    /// Req 5.3: a place whose rows disagree about reader-authored content is+    /// disclosed and read-only, exactly as a torn character is.+    @Test("A torn place is presented torn, with its row count (5.3)")+    func tornPlaceIsPresentedTorn() async throws {+        let fixture = try await M5Fixture()+        let workID = UUID()+        let bay = UUID()+        try await fixture.repository.seedM5Rows(+            sites: [M5SeedSite(hostname: "example.com")],+            works: [M5SeedWork(id: workID, displayTitle: "A Serial", hostname: "example.com")],+            places: [+                M5SeedPlace(+                    id: bay, name: "Ward Bay", nameKey: "ward bay", note: "one", workID: workID),+                M5SeedPlace(+                    id: bay, name: "Ward Bay", nameKey: "ward bay", note: "two", workID: workID),+            ])++        let place = try #require(try await fixture.repository.workDetail(id: workID).places.first)+        #expect(place.isTorn)+        #expect(place.rowCount == 2)+    }+     @Test("A work with no entries has no pulse, no rows and no last-noted URL (5.3)")     func emptyWork() async throws {         let fixture = try await M5Fixture()@@ -523,5 +710,7 @@ struct WorkDetailReadTests {         #expect(detail.pulse == RatingPulse(notes: 0, up: 0, down: 0))         #expect(detail.chapterRows.isEmpty)         #expect(detail.lastNotedURLString == nil)+        #expect(detail.characters.isEmpty)+        #expect(detail.places.isEmpty, "Req 4.1: no places, so no place-related element")     } }
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 3160401..d83860e 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: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV12MigrationPlan.self,+                for: schema, migrationPlan: AsterismV13MigrationPlan.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 6ed63b7..d426448 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: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV12MigrationPlan.self,+                for: schema, migrationPlan: AsterismV13MigrationPlan.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 d12bbaa..c83d515 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: AsterismSchemaV12.self)+            let schema = Schema(versionedSchema: AsterismSchemaV13.self)             let configuration = ModelConfiguration(                 "AsterismV3", schema: schema,                 url: directory.appending(path: "library.store"), cloudKitDatabase: .none)             container = try ModelContainer(-                for: schema, migrationPlan: AsterismV12MigrationPlan.self,+                for: schema, migrationPlan: AsterismV13MigrationPlan.self,                 configurations: [configuration])             context = ModelContext(container)         }
Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift Modified +8 / -8
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swiftindex 6389fb2..e17e950 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WriteSiteRelationshipTests.swift@@ -268,7 +268,7 @@ struct WriteSiteRelationshipTests {     /// format change, no marker republication, no second pass.     @Test("materializeArchive wires both relationships from its sitesByHostname map")     func materializeWiresBothRelationships() throws {-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let container = try ModelContainer(             for: schema,             configurations: [ModelConfiguration(@@ -276,7 +276,7 @@ struct WriteSiteRelationshipTests {         let context = ModelContext(container)          try LibraryRepository.materializeArchive(-            BackupImportPayload(BackupV11Fixtures.minimalTaughtPayload()), into: context)+            BackupImportPayload(BackupV12Fixtures.minimalTaughtPayload()), into: context)          let site = try #require(try context.fetch(FetchDescriptor<Site>()).first)         let entry = try #require(try context.fetch(FetchDescriptor<Entry>()).first)@@ -297,7 +297,7 @@ struct WriteSiteRelationshipTests {         let dir = try TempDir("WriteSiteImportFill")         let cfg = configuration(dir)         let (_, repository) = try await LibraryRepository.openForApp(cfg)-        #expect(try markerContent(cfg) == "12",+        #expect(try markerContent(cfg) == "13",                 "mark-at-birth certifies an empty store at the current generation")          let plan = try importPlan()@@ -306,7 +306,7 @@ struct WriteSiteRelationshipTests {             Issue.record("expected committed, got \(result)")             return         }-        #expect(try markerContent(cfg) == "12", "the import republishes nothing")+        #expect(try markerContent(cfg) == "13", "the import republishes nothing")         try expectEveryRelationshipPopulated(cfg)         withExtendedLifetime(dir) {}     }@@ -324,7 +324,7 @@ struct WriteSiteRelationshipTests {             Issue.record("expected committed, got \(result)")             return         }-        #expect(try markerContent(cfg) == "12")+        #expect(try markerContent(cfg) == "13")         try expectEveryRelationshipPopulated(cfg)         withExtendedLifetime(dir) {}     }@@ -411,7 +411,7 @@ struct WriteSiteRelationshipTests {     }      private func importPlan() throws -> BackupImportPlan {-        let payload = BackupImportPayload(BackupV11Fixtures.minimalTaughtPayload())+        let payload = BackupImportPayload(BackupV12Fixtures.minimalTaughtPayload())         return BackupImportPlan(             metadata: BackupImportMetadata(                 formatVersion: 8, schemaVersion: 9, appBuild: "test",@@ -421,7 +421,7 @@ struct WriteSiteRelationshipTests {             counts: try LibraryRepository.validateImportPlanPayload(payload))     } -    /// A nonempty store certified at the current generation (`"12"`) — the state+    /// A nonempty store certified at the current generation (`"13"`) — the state     /// an import replaces into.     private func seedCertifiedLibrary(_ cfg: LibraryConfiguration, hostname: String) throws {         try FileManager.default.createDirectory(@@ -438,7 +438,7 @@ struct WriteSiteRelationshipTests {         entry.site = site         context.insert(entry)         try context.save()-        try Data("12\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)+        try Data("13\n".utf8).write(to: cfg.readinessMarkerURL, options: .atomic)         withExtendedLifetime(container) {}     } 
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift Modified +5 / -5
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swiftindex e328c1b..1cb3f13 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLCompatibilityTests.swift@@ -46,8 +46,8 @@ struct WrongHostWorkURLCompatibilityTests {         // The file: format 9 over schema 10, the pair every archive since         // `work-and-reading-status` carries. The heal did not move it; the         // status columns did (Q17).-        #expect(BackupV11Document.formatVersion == 11)-        #expect(BackupV11Document.schemaVersion == 12)+        #expect(BackupV12Document.formatVersion == 12)+        #expect(BackupV12Document.schemaVersion == 13)     }      @Test("A healed library's archive uses only the membership keys 9/10 declared")@@ -85,7 +85,7 @@ struct WrongHostWorkURLCompatibilityTests {         // The archive: the minted row is there, and so is the untaught wire Site         // the projection synthesised for its hostname — without which the         // reference checks would refuse a membership naming no Site.-        let decoded = try BackupV11Codec.decode(archive)+        let decoded = try BackupV12Codec.decode(archive)         let minted = try #require(             decoded.payload.memberships.first { $0.hostname == Self.destination })         #expect(minted.workID == workID)@@ -151,10 +151,10 @@ private struct CompatibilityRoot {         let outcome = try await repository.reconcileAfterSync()         #expect(outcome.memberships.movedWorkURLs == 1) -        let exporter = BackupV11Exporter(+        let exporter = BackupV12Exporter(             repository: repository, stagingDirectory: directory.appending(path: "staging"))         let result = try await exporter.export(-            metadata: BackupV11Metadata(+            metadata: BackupV12Metadata(                 appBuild: "test-1.0",                 exportedAt: WrongHostWorkURLCompatibilityTests.epoch))         let data = try Data(contentsOf: result.fileURL)
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift Modified +9 / -9
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swiftindex 2429f3d..4efa7c6 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLImportTests.swift@@ -311,7 +311,7 @@ struct WrongHostWorkURLImportTests {             // A legacy identity state with no identity value: an illegal             // membership tuple that has nothing to do with the Work URL.             [-                BackupV11Membership(+                BackupV12Membership(                     id: work, workID: work, hostname: siteHostname, createdAt: epoch,                     urlIdentity: nil, urlIdentityState: .legacyUnverified,                     urlIdentityRuleID: nil, workURLString: nil)@@ -401,14 +401,14 @@ private let epoch = Date(timeIntervalSince1970: 1_800_000_000)  private func record(     id: UUID = UUID(), work: UUID?, hostname: String, workURL: String?-) -> BackupV11Membership {-    BackupV11Membership(+) -> BackupV12Membership {+    BackupV12Membership(         id: id, workID: work, hostname: hostname, createdAt: epoch,         urlIdentity: nil, urlIdentityState: .none, urlIdentityRuleID: nil,         workURLString: workURL) } -private func url(of records: [BackupV11Membership], _ id: UUID) -> String? {+private func url(of records: [BackupV12Membership], _ id: UUID) -> String? {     records.first { $0.id == id }?.workURLString ?? nil } @@ -418,14 +418,14 @@ private func url(of records: [BackupV11Membership], _ id: UUID) -> String? { /// a test can address them. private func makePlan(     activePattern: Bool = true,-    memberships: (_ workID: UUID, _ otherMembershipID: UUID) -> [BackupV11Membership]+    memberships: (_ workID: UUID, _ otherMembershipID: UUID) -> [BackupV12Membership] ) -> BackupImportPlan {     let workID = UUID()     let otherMembershipID = UUID()     let patternID = UUID()     let rawURL = "https://\(siteHostname)/chapter/1" -    let entry = BackupV11Entry(+    let entry = BackupV12Entry(         id: UUID(), captureTitle: "Chapter 1", captureTitleSource: .host,         rawURL: rawURL, canonicalURL: nil, hostname: siteHostname,         entryIdentityKey: rawURL, conservativeIdentityKey: rawURL,@@ -435,7 +435,7 @@ private func makePlan(         intentionallyUnattached: false,         citations: EntryCitations(             workAssignment: .pattern(CitedRule(id: patternID))))-    let work = BackupV11Work(+    let work = BackupV12Work(         id: workID, displayTitle: "Imported Work", lastParsedTitle: "Imported Work",         genericNotes: "", genreTags: [], titleProvenance: .parsed,         workStatus: .ongoing, readingStatus: .reading, verdict: "", workTypeID: nil,@@ -444,13 +444,13 @@ private func makePlan(     // `activePattern` varies is whether it is *active*, which is what makes the     // taught Site's tuple legal or not.     let patterns = [-        BackupV11TitlePattern(+        BackupV12TitlePattern(             id: patternID, siteHostname: siteHostname, version: 1, isActive: activePattern,             createdAt: epoch,             definition: StoredPatternDefinition(definition: .wholeTitle))     ]     let sites = [siteHostname, otherHostname].map {-        BackupV11Site(hostname: $0, displayName: $0, mode: $0 == siteHostname ? .taught : .untaught,+        BackupV12Site(hostname: $0, displayName: $0, mode: $0 == siteHostname ? .taught : .untaught,                      junkSuffixRule: nil)     }     let payload = BackupImportPayload(
Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift Modified +2 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swiftindex 8d6346f..9b4b919 100644--- a/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift+++ b/Packages/AsterismCore/Tests/AsterismCoreTests/WrongHostWorkURLValidatorTests.swift@@ -407,12 +407,12 @@ final class WrongHostStore {         directory = FileManager.default.temporaryDirectory             .appending(path: "AsterismWrongHostWorkURL-\(UUID())", directoryHint: .isDirectory)         try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)-        let schema = Schema(versionedSchema: AsterismSchemaV12.self)+        let schema = Schema(versionedSchema: AsterismSchemaV13.self)         let configuration = ModelConfiguration(             "AsterismV3", schema: schema,             url: directory.appending(path: "library.store"), cloudKitDatabase: .none)         container = try ModelContainer(-            for: schema, migrationPlan: AsterismV12MigrationPlan.self,+            for: schema, migrationPlan: AsterismV13MigrationPlan.self,             configurations: [configuration])         context = ModelContext(container)     }
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift Modified +291 / -22
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swiftindex a473733..73c22a6 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift@@ -20,12 +20,13 @@ struct CharacterExtractionAssemblerTests {         .entry(entryA): "fp-a", .entry(entryB): "fp-b", .genericNotes: "fp-g",     ] -    static func candidate(_ name: String, aliases: [String] = [],+    static func candidate(_ name: String, kind: RecordKind = .character,+                          aliases: [String] = [],                           source: SourceRef = .entry(entryA),                           quotes: [String] = []) -> GroundedCandidate {-        let key = CharacterNameKey.normalize(name)+        let key = RecordNameKey.normalize(name)         return GroundedCandidate(-            name: name, nameKey: key, proposedAliases: aliases, source: source,+            name: name, nameKey: key, kind: kind, proposedAliases: aliases, source: source,             facts: quotes.map {                 GroundedFact(nameKey: key, statement: "says \($0)", quote: $0, source: source)             })@@ -34,7 +35,7 @@ struct CharacterExtractionAssemblerTests {     static func character(_ name: String, id: UUID, retainedKey: String? = nil,                           aliases: [String] = []) -> ExistingCharacter {         ExistingCharacter(id: id, name: name,-                          retainedKey: retainedKey ?? CharacterNameKey.normalize(name),+                          retainedKey: retainedKey ?? RecordNameKey.normalize(name),                           aliases: aliases)     } @@ -99,7 +100,7 @@ struct CharacterExtractionAssemblerTests {     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("Terawatt", id: Self.lowUUID, retainedKey: RecordNameKey.normalize("Alex")),             Self.character("Mira", id: Self.highUUID, aliases: ["Sparks"]),         ]) @@ -112,14 +113,14 @@ struct CharacterExtractionAssemblerTests {         #expect(target("Terawatt") == .existing(Self.lowUUID))         #expect(target("Alex") == .existing(Self.lowUUID))         #expect(target("Sparks") == .existing(Self.highUUID))-        #expect(target("Nobody") == .newCharacter)+        #expect(target("Nobody") == .newRecord)     }      @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("Renamed", id: Self.lowUUID, retainedKey: RecordNameKey.normalize("Mira")),             Self.character("Mira", id: Self.highUUID),         ]) @@ -147,7 +148,7 @@ struct CharacterExtractionAssemblerTests {         let proposals = Self.assemble([Self.candidate("Hanna", aliases: ["Action Girl"])],                                       context: context) -        #expect(proposals.first?.target == .newCharacter)+        #expect(proposals.first?.target == .newRecord)         #expect(proposals.first?.proposedAliases == ["Action Girl"])     } @@ -194,7 +195,7 @@ struct CharacterExtractionAssemblerTests {         let proposals = Self.assemble([Self.candidate("Hanna")])          #expect(proposals.count == 1)-        #expect(proposals.first?.target == .newCharacter)+        #expect(proposals.first?.target == .newRecord)         #expect(proposals.first?.facts.isEmpty == true)     } @@ -202,12 +203,12 @@ struct CharacterExtractionAssemblerTests {      @Test("A bundle's facts are re-keyed to the target's retained key before dedup")     func factsAreKeyedToTheResolvedCharacter() {-        let retained = CharacterNameKey.normalize("Terawatt")+        let retained = RecordNameKey.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),+            acceptedFacts: [RecordFactIdentity(nameKey: retained, source: .entry(Self.entryA),                                          quote: "she rewires the grid")])          // The model spelled her name the other way this time. Without the@@ -228,7 +229,7 @@ struct CharacterExtractionAssemblerTests {     func newCandidateKeepsItsOwnKey() {         let proposals = Self.assemble([Self.candidate("Hanna", quotes: ["Hanna arrives"])]) -        #expect(proposals.first?.facts.first?.nameKey == CharacterNameKey.normalize("Hanna"))+        #expect(proposals.first?.facts.first?.nameKey == RecordNameKey.normalize("Hanna"))     }      @Test("Duplicate facts within one pass collapse to one row")@@ -246,10 +247,10 @@ struct CharacterExtractionAssemblerTests {     @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 key = RecordNameKey.normalize("Hanna")         let context = CharacterExtractionContext(             characters: [Self.character("Hanna", id: Self.lowUUID)],-            acceptedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+            acceptedFacts: [RecordFactIdentity(nameKey: key, source: .entry(Self.entryA),                                          quote: "Hanna arrives")])          let proposals = Self.assemble(@@ -262,10 +263,10 @@ struct CharacterExtractionAssemblerTests {      @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 key = RecordNameKey.normalize("Hanna")         let context = CharacterExtractionContext(             characters: [Self.character("Hanna", id: Self.lowUUID)],-            suppressedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+            suppressedFacts: [RecordFactIdentity(nameKey: key, source: .entry(Self.entryA),                                            quote: "Hanna arrives")])         let candidates = [Self.candidate("Hanna", source: .entry(Self.entryA),                                          quotes: ["Hanna arrives"])]@@ -277,7 +278,7 @@ struct CharacterExtractionAssemblerTests {     @Test("A suppressed name key blocks a new candidate on the sweep only")     func suppressedNameKeyBlocksNewCandidates() {         let context = CharacterExtractionContext(-            suppressedNameKeys: [CharacterNameKey.normalize("Hanna")])+            suppressedNameKeys: [RecordNameKey.normalize("Hanna")])         let candidates = [Self.candidate("Hanna", quotes: ["Hanna arrives"])]          #expect(Self.assemble(candidates, context: context, pass: .automatic).isEmpty)@@ -288,7 +289,7 @@ struct CharacterExtractionAssemblerTests {     func suppressedNameKeyDoesNotBlockBundles() {         let context = CharacterExtractionContext(             characters: [Self.character("Hanna", id: Self.lowUUID)],-            suppressedNameKeys: [CharacterNameKey.normalize("Hanna")])+            suppressedNameKeys: [RecordNameKey.normalize("Hanna")])          let proposals = Self.assemble([Self.candidate("Hanna", quotes: ["Hanna arrives"])],                                       context: context, pass: .automatic)@@ -299,10 +300,10 @@ struct CharacterExtractionAssemblerTests {      @Test("A candidate emptied by dedup alone is not shown, but its name-only sibling is")     func candidateEmptiedByDedupIsNotShown() {-        let key = CharacterNameKey.normalize("Hanna")+        let key = RecordNameKey.normalize("Hanna")         let context = CharacterExtractionContext(             characters: [Self.character("Hanna", id: Self.lowUUID)],-            acceptedFacts: [CharacterFactIdentity(nameKey: key, source: .entry(Self.entryA),+            acceptedFacts: [RecordFactIdentity(nameKey: key, source: .entry(Self.entryA),                                          quote: "Hanna arrives")])          // Everything it had is already accepted: nothing left to decide.@@ -319,7 +320,275 @@ struct CharacterExtractionAssemblerTests {     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")])+        #expect(proposal?.nameKey == RecordNameKey.normalize("Hanna"))+        #expect(proposal?.aliasKeys == [RecordNameKey.normalize("Action Girl")])+    }++    // MARK: - Per-kind matching and filtering (Req 1.6, Q13)++    static let bayKey = RecordNameKey.normalize("Bay")++    static func context(characters: [ExistingCharacter] = [], places: [ExistingCharacter] = [],+                        acceptedCharacterFacts: Set<RecordFactIdentity> = [],+                        acceptedPlaceFacts: Set<RecordFactIdentity> = [],+                        suppressedCharacterKeys: Set<String> = [],+                        suppressedPlaceKeys: Set<String> = [],+                        suppressedPlaceFacts: Set<RecordFactIdentity> = [])+        -> CharacterExtractionContext {+        CharacterExtractionContext(+            records: [.character: characters, .place: places],+            acceptedFacts: [.character: acceptedCharacterFacts, .place: acceptedPlaceFacts],+            suppressedNameKeys: [.character: suppressedCharacterKeys,+                                 .place: suppressedPlaceKeys],+            suppressedFacts: [.place: suppressedPlaceFacts])+    }++    @Test("Each kind resolves against its own records: a character and a place may share a name")+    func matchingIsPerKind() {+        let context = Self.context(characters: [Self.character("Bay", id: Self.lowUUID)],+                                   places: [Self.character("Bay", id: Self.highUUID)])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, source: .entry(Self.entryB),+                           quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.map(\.kind) == [.character, .place])+        #expect(proposals.map(\.target) == [.existing(Self.lowUUID), .existing(Self.highUUID)])+    }++    @Test("A place skip suppresses places only: the same name still comes back as a character")+    func suppressionIsPerKind() {+        let context = Self.context(suppressedPlaceKeys: [Self.bayKey])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, source: .entry(Self.entryB),+                           quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.map(\.kind) == [.character])+        #expect(proposals.first?.facts.map(\.quote) == ["Bay smiles"])+    }++    @Test("A fact accepted under one kind does not dedup the same words under the other")+    func factDedupIsPerKind() {+        let accepted = RecordFactIdentity(nameKey: Self.bayKey, source: .entry(Self.entryA),+                                          quote: "the Bay is grim")+        let context = Self.context(places: [Self.character("Bay", id: Self.highUUID)],+                                   acceptedPlaceFacts: [accepted])++        // The place bundle has nothing new; the character candidate keeps the+        // same words, because they were never accepted under its kind (Q13).+        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["the Bay is grim"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.map(\.kind) == [.character])+        #expect(proposals.first?.facts.map(\.quote) == ["the Bay is grim"])+    }++    /// Q36 spells out what "survives filtering" means, because Req 1.5's+    /// dual-kind rule runs over the survivors and two engineers would not build+    /// the predicate the same way.+    @Test("An unmatched copy survives with no facts; a matched one needs a new fact or alias (Q36)")+    func survivalPredicate() {+        let context = Self.context(places: [Self.character("Docks", id: Self.lowUUID)],+                                   suppressedPlaceKeys: [RecordNameKey.normalize("Tokyo")])++        let proposals = Self.assemble([+            // Unmatched, no facts at all: the name itself is new content (Q25).+            Self.candidate("Boardwalk", kind: .place),+            // Unmatched, but its key is suppressed under its own kind.+            Self.candidate("Tokyo", kind: .place, quotes: ["Tokyo is quiet"]),+            // Matched with nothing the record does not already have.+            Self.candidate("Docks", kind: .place),+        ], context: context)++        #expect(proposals.map(\.name) == ["Boardwalk"])+        #expect(proposals.first?.target == .newRecord)+        #expect(proposals.first?.facts.isEmpty == true)+    }++    // MARK: - The dual-kind rule (Req 1.5, Q12, Q18, Q20, Q22)++    @Test("Both copies unmatched: one character row carrying the union, marked under both kinds")+    func dualKindBothUnmatched() {+        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim", "Bay smiles"]),+        ])++        #expect(proposals.count == 1, "one row, so one decision covers both readings (Q12)")+        let bay = proposals.first+        // Q20: a dual-kind candidate with no match displays as a character.+        #expect(bay?.kind == .character)+        #expect(bay?.displayedKind == .character)+        #expect(bay?.returnedKinds == [.character, .place])+        #expect(bay?.target == .newRecord)+        // The union, re-deduped under the character kind: the repeated quote+        // arrives once, and every fact is keyed to the character copy's key.+        #expect(bay?.facts.map(\.quote) == ["Bay smiles", "the Bay is grim"])+        #expect(bay?.facts.allSatisfy { $0.nameKey == Self.bayKey } == true)+    }++    @Test("Exactly one copy matched: the bundle and the other kind's candidate both stand (Q18)")+    func dualKindOneMatched() {+        let context = Self.context(characters: [Self.character("Bay", id: Self.lowUUID)])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.map(\.kind) == [.character, .place])+        #expect(proposals.map(\.target) == [.existing(Self.lowUUID), .newRecord])+        // Q33: neither row is a union row, so a skip of either suppresses its+        // own kind only.+        #expect(proposals.allSatisfy { $0.returnedKinds.count == 1 })+        #expect(proposals.map { $0.facts.map(\.quote) } == [["Bay smiles"], ["the Bay is grim"]])+    }++    @Test("Records of both kinds matched: one bundle per kind (Q22)")+    func dualKindBothMatched() {+        let context = Self.context(characters: [Self.character("Bay", id: Self.lowUUID)],+                                   places: [Self.character("Bay", id: Self.highUUID)])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.map(\.target) == [.existing(Self.lowUUID), .existing(Self.highUUID)])+        #expect(proposals.allSatisfy { $0.returnedKinds.count == 1 })+    }++    /// Q68: Req 1.5 and Q36 both restate the survival predicate as "unmatched+    /// and unsuppressed survives", which is narrower than what the assembler+    /// does and always did — `character-extraction` Req 1.7's rule that a row+    /// with nothing left to decide is not shown applies to an unmatched copy+    /// too, as long as the model reported facts for it.+    ///+    /// The consequence is the half worth pinning: such a copy is not there for+    /// the dual-kind rule to fold, so the surviving copy is single-kind and a+    /// skip of it suppresses one kind.+    @Test("A copy whose every reported fact deduped away is not shown, and unions nothing (Q68)")+    func factuallyEmptyCopyIsNotShownAndCannotUnion() {+        let context = Self.context(+            acceptedPlaceFacts: [+                RecordFactIdentity(nameKey: Self.bayKey, source: .entry(Self.entryA),+                                   quote: "the Bay is grim")+            ])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.count == 1)+        #expect(proposals.first?.kind == .character)+        #expect(proposals.first?.returnedKinds == [.character],+                "the place copy is not shown, so it is not there to be unioned")+        #expect(proposals.first?.facts.map(\.quote) == ["Bay smiles"])++        // The contrast, so the rule is not read as "an empty copy never+        // unions": a copy the model reported *name-only* has nothing to dedup+        // away, and it is new content in its own right (Q25).+        let nameOnly = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place),+        ], context: context)++        #expect(nameOnly.count == 1)+        #expect(nameOnly.first?.returnedKinds == [.character, .place])+    }++    @Test("A copy filtered out leaves a plain single-kind candidate, not a union row (Q30)")+    func filteredCopyLeavesASingleKindRow() {+        let context = Self.context(suppressedPlaceKeys: [Self.bayKey])++        let proposals = Self.assemble([+            Self.candidate("Bay", quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ], context: context)++        #expect(proposals.count == 1)+        #expect(proposals.first?.returnedKinds == [.character])+        // The filtered copy's facts are not carried across (Q30).+        #expect(proposals.first?.facts.map(\.quote) == ["Bay smiles"])+    }++    @Test("The rule never crosses source responses: two sources, two rows, neither dual (Req 1.4)")+    func dualKindNeverCrossesSources() {+        let proposals = Self.assemble([+            Self.candidate("Bay", source: .entry(Self.entryA), quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, source: .entry(Self.entryB),+                           quotes: ["the Bay is grim"]),+        ])++        #expect(proposals.map(\.kind) == [.character, .place])+        #expect(proposals.allSatisfy { $0.returnedKinds.count == 1 })+        #expect(proposals.allSatisfy { $0.target == .newRecord })+    }++    @Test("Grouping across sources unions the kinds the pass returned a key under")+    func crossSourceGroupingUnionsReturnedKinds() {+        let proposals = Self.assemble([+            // One response returned the name under both kinds…+            Self.candidate("Bay", source: .entry(Self.entryA), quotes: ["Bay smiles"]),+            Self.candidate("Bay", kind: .place, source: .entry(Self.entryA),+                           quotes: ["the Bay is grim"]),+            // …and another returned it as a character only.+            Self.candidate("Bay", source: .entry(Self.entryB), quotes: ["Bay leaves"]),+        ])++        #expect(proposals.count == 1)+        #expect(proposals.first?.returnedKinds == [.character, .place])+        #expect(proposals.first?.facts.count == 3)+        #expect(proposals.first?.citedRevisions+            == [.entry(Self.entryA): "fp-a", .entry(Self.entryB): "fp-b"])+    }++    // MARK: - Per-kind caps (Req 1.3, Q15)++    @Test("Each kind is capped by its own constant, so neither crowds the other out")+    func perKindCaps() {+        let characters = (0 ..< (CharacterExtractionBounds.maximumCandidates * 2)).map {+            Self.candidate("Char\(String(format: "%03d", $0))")+        }+        let places = (0 ..< (CharacterExtractionBounds.maximumPlaceCandidates * 2)).map {+            Self.candidate("Place\(String(format: "%03d", $0))", kind: .place)+        }++        let proposals = Self.assemble(characters + places)++        #expect(proposals.filter { $0.kind == .character }.count+            == CharacterExtractionBounds.maximumCandidates)+        #expect(proposals.filter { $0.kind == .place }.count+            == CharacterExtractionBounds.maximumPlaceCandidates)+    }+}++// MARK: - The character-only shape, test-side++extension CharacterExtractionContext {+    /// A context whose place half is empty.+    ///+    /// **Test-side on purpose.** It stood in `AsterismIntelligence` and was+    /// deleted (task 17 review of `place-extraction`): a production caller that+    /// reached for it compiled and got an empty place half, filtering every+    /// place candidate through no context at all. The suites above are saying+    /// "this pass has no places", which is a statement about a fixture rather+    /// than about the library, so the convenience belongs here.+    init(characters: [ExistingCharacter] = [],+         acceptedFacts: Set<RecordFactIdentity> = [],+         suppressedNameKeys: Set<String> = [],+         suppressedFacts: Set<RecordFactIdentity> = []) {+        self.init(records: [.character: characters],+                  acceptedFacts: [.character: acceptedFacts],+                  suppressedNameKeys: [.character: suppressedNameKeys],+                  suppressedFacts: [.character: suppressedFacts])     } }
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swift Added +174 / -0
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swiftnew file mode 100644index 0000000..40bd8e3--- /dev/null+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionBridgeTests.swift@@ -0,0 +1,174 @@+import AsterismCore+import Foundation+import Testing++@testable import AsterismIntelligence++/// The seam a held row crosses on its way to the store: `decisionRequest`.+///+/// What is pinned here is the three fields the commit routes on — the kind the+/// row was **displayed** under (Q24, Q28), the kinds the pass returned it under+/// (Req 1.5), and the target it was **shown against** (Q66) — because a+/// reclassified row is exactly the case where the assembled values and the+/// displayed ones disagree, and sending the assembled ones would commit a+/// decision of one kind against a record of the other.+///+/// The request is built from whatever proposal it is called on, which is how+/// the review model hands it the **projected** row rather than the held one+/// (Q59): the projection is an `ExtractionProposal` whose facts have already+/// been re-keyed and re-deduped under the destination kind.+@Suite("CharacterExtractionBridge")+struct CharacterExtractionBridgeTests {++    static let entryA = UUID(uuidString: "00000000-0000-0000-0000-00000000A001")!+    static let entryB = UUID(uuidString: "00000000-0000-0000-0000-00000000B002")!+    static let workID = UUID(uuidString: "00000000-0000-0000-0000-0000000000F0")!+    static let recordID = UUID(uuidString: "00000000-0000-0000-0000-0000000000C1")!++    static func fact(_ quote: String, key: String = "bay",+                     source: SourceRef = .entry(entryA)) -> GroundedFact {+        GroundedFact(nameKey: key, statement: "says \(quote)", quote: quote, source: source)+    }++    static func proposal(+        kind: RecordKind = .character,+        displayedKind: RecordKind? = nil,+        returnedKinds: Set<RecordKind>? = nil,+        target: ExtractionProposal.Target = .newRecord,+        aliases: [String] = [],+        facts: [GroundedFact] = [],+        citedRevisions: [SourceRef: String] = [.entry(entryA): "fp-a"]+    ) -> ExtractionProposal {+        ExtractionProposal(+            name: "Bay", nameKey: "bay", kind: kind, proposedAliases: aliases,+            target: target, facts: facts, citedRevisions: citedRevisions,+            displayedKind: displayedKind, returnedKinds: returnedKinds)+    }++    // MARK: - The kind the commit writes under (Q24, Q28)++    @Test("An undisturbed row commits under the kind it was assembled as")+    func requestCarriesTheAssembledKind() {+        let request = Self.proposal(kind: .place)+            .decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.kind == .place)+        #expect(request.returnedKinds == [.place])+        #expect(request.suppressedKinds == [.place])+    }++    @Test("A reclassified row commits under the kind it displays, not the one it was assembled as")+    func requestCarriesTheDisplayedKind() {+        let request = Self.proposal(kind: .character, displayedKind: .place)+            .decisionRequest(workID: Self.workID, action: .skip)++        #expect(request.kind == .place)+        // Q33: the row was returned under one kind, so a skip of it suppresses+        // one kind — the one it was decided under, and the character key it was+        // assembled under stays free.+        #expect(request.returnedKinds == [.character])+        #expect(request.suppressedKinds == [.place])+    }++    @Test("A union row carries both returned kinds, so a skip of it suppresses both (Req 2.4)")+    func requestCarriesBothReturnedKinds() {+        let request = Self.proposal(returnedKinds: [.character, .place])+            .decisionRequest(workID: Self.workID, action: .skip)++        #expect(request.kind == .character)+        #expect(request.returnedKinds == [.character, .place])+        #expect(request.suppressedKinds == [.character, .place])+        #expect(request.nameKeySuppressedKinds == [.character, .place],+                "the skip wrote them, so the gated reading names them too")+    }++    /// Q77: the same union row **accepted**. `suppressedKinds` still names both+    /// kinds — it answers "which kinds would a name-key suppression be written+    /// under", and an accept writes none — so the field a cross-kind sweep may+    /// read is the gated one, which is empty here. Req 2.4: an accept must not+    /// touch the other kind.+    @Test("An accepted union row wrote no name-key suppression, under either kind")+    func acceptedUnionRowSuppressesNoNameKey() {+        let request = Self.proposal(returnedKinds: [.character, .place])+            .decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.suppressedKinds == [.character, .place])+        #expect(request.nameKeySuppressedKinds == [])+    }++    /// The other half of the gate: a bundle skip suppresses no name key at all+    /// (Q33, Q47), so there is nothing for the sweep to spread even though the+    /// row was returned under both kinds.+    @Test("A bundle skip wrote no name-key suppression")+    func bundleSkipSuppressesNoNameKey() {+        let request = Self.proposal(+            returnedKinds: [.character, .place], target: .existing(Self.recordID)+        ).decisionRequest(workID: Self.workID, action: .skip)++        #expect(request.nameKeySuppressedKinds == [])+    }++    // MARK: - The target the row was shown against (Q66)++    @Test("A bundle carries the record it was displayed against")+    func requestCarriesTheDisplayedTarget() {+        let request = Self.proposal(target: .existing(Self.recordID))+            .decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.displayedTargetID == Self.recordID)+    }++    @Test("A candidate carries no target at all")+    func candidateCarriesNoTarget() {+        let request = Self.proposal().decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.displayedTargetID == nil)+    }++    /// Q24/Q59: the reclassify preview resolves a target under the *new* kind+    /// and writes it onto the row, so the request built from that row names the+    /// record the reader was actually shown.+    @Test("A row reclassified onto an existing record of the other kind carries that record")+    func reclassifiedRowCarriesThePreviewedTarget() {+        let request = Self.proposal(+            kind: .character, displayedKind: .place, target: .existing(Self.recordID))+            .decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.kind == .place)+        #expect(request.displayedTargetID == Self.recordID)+    }++    // MARK: - What the row displayed (Q92), unchanged++    @Test("The displayed keys are the name key plus the aliases the reader left standing")+    func requestCarriesDisplayedKeys() {+        let request = Self.proposal(aliases: ["Bay Head", "The Bay"])+            .decisionRequest(workID: Self.workID, action: .skip, struckAliases: ["The Bay"])++        #expect(request.displayedKeys == ["bay", "bay head"])+        #expect(request.proposedAliases == ["Bay Head"])+    }++    @Test("Unticked facts travel separately from the ones the decision acts on")+    func requestSplitsUntickedFacts() {+        let kept = Self.fact("Bay smiles")+        let unticked = Self.fact("the Bay is grim")+        let request = Self.proposal(facts: [kept, unticked])+            .decisionRequest(+                workID: Self.workID, action: .accept, untickedFacts: [unticked.identity])++        #expect(request.facts.map(\.quote) == ["Bay smiles"])+        #expect(request.untickedFacts == [unticked.identity])+    }++    @Test("The sources a decision completes are the row's cited revisions, in a stable order")+    func requestCarriesCompletedSources() {+        let request = Self.proposal(citedRevisions: [+            .entry(Self.entryB): "fp-b", .genericNotes: "fp-g", .entry(Self.entryA): "fp-a",+        ]).decisionRequest(workID: Self.workID, action: .accept)++        #expect(request.completedSources.map(\.ref)+            == [.genericNotes, .entry(Self.entryA), .entry(Self.entryB)])+        #expect(request.completedSources.map(\.fingerprint) == ["fp-g", "fp-a", "fp-b"])+    }+}
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift Modified +141 / -21
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swiftindex 7025d76..3049cfc 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift@@ -21,6 +21,7 @@ struct CharacterExtractionLedgerTests {     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 placeID = UUID(uuidString: "00000000-0000-0000-0000-0000000000D4")!      static let foreground = ModelWorkEnvironment() @@ -29,18 +30,31 @@ struct CharacterExtractionLedgerTests {         ExtractionSourceKey(work: work, source: source)     } -    static func proposal(_ name: String, target: ExtractionProposal.Target = .newCharacter,+    static func proposal(_ name: String, kind: RecordKind = .character,+                         target: ExtractionProposal.Target = .newRecord,                          cites: [SourceRef: String] = [.entry(entry1): "fp-1"],                          source: SourceRef = .entry(entry1),-                         quotes: [String] = [])+                         quotes: [String] = [],+                         returnedKinds: Set<RecordKind>? = nil)         -> ExtractionProposal {-        let key = CharacterNameKey.normalize(name)+        let key = RecordNameKey.normalize(name)         return ExtractionProposal(-            name: name, nameKey: key, proposedAliases: [], target: target,+            name: name, nameKey: key, kind: kind, proposedAliases: [], target: target,             facts: quotes.map {                 GroundedFact(nameKey: key, statement: "says \($0)", quote: $0, source: source)             },-            citedRevisions: cites)+            citedRevisions: cites, returnedKinds: returnedKinds)+    }++    static func proposalKey(_ name: String, kind: RecordKind = .character) -> ProposalKey {+        ProposalKey(kind: kind, nameKey: RecordNameKey.normalize(name))+    }++    static func state(revisions: [SourceRef: String] = [.entry(entry1): "fp-1"],+                      characters: Set<UUID> = [], places: Set<UUID> = [])+        -> WorkExtractionState {+        WorkExtractionState(revisions: revisions,+                            recordIDs: [.character: characters, .place: places])     }      /// A ledger mid-sweep with one attempt running for `key`.@@ -286,8 +300,7 @@ struct CharacterExtractionLedgerTests {         #expect(held.first?.citedRevisions                 == [.entry(Self.entry1): "fp-1", .genericNotes: "fp-g"]) -        let discarded = ledger.discard(nameKey: CharacterNameKey.normalize("Hanna"),-                                       for: Self.workA)+        let discarded = ledger.discard(Self.proposalKey("Hanna"), for: Self.workA)         #expect(discarded)         #expect(ledger.held(for: Self.workA).isEmpty, "one decision decides the whole row")     }@@ -298,18 +311,98 @@ struct CharacterExtractionLedgerTests {         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)+        let discardedHanna = ledger.discard(Self.proposalKey("Hanna"), for: Self.workA)+        let discardedNobody = ledger.discard(+            ProposalKey(kind: .character, 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)+        let discardedGrover = ledger.discard(Self.proposalKey("Grover"), for: Self.workA)         #expect(discardedGrover)         #expect(ledger.worksWithProposals.isEmpty, "an emptied work leaves no indicator behind")     } +    // MARK: - Two kinds, one held list (Q28, Q56)++    @Test("A character and a place of one name are two rows, told apart by their proposal key")+    func proposalKeyIdentityAcrossKinds() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([+            Self.proposal("Bay", quotes: ["Bay smiles"]),+            Self.proposal("Bay", kind: .place, quotes: ["the Bay is grim"]),+        ]), modelPhase: .seconds(1))++        #expect(ledger.held(for: Self.workA).map(\.kind) == [.character, .place])++        let discarded = ledger.discard(Self.proposalKey("Bay", kind: .place), for: Self.workA)+        #expect(discarded)+        #expect(ledger.held(for: Self.workA).map(\.kind) == [.character],+                "one kind's decision leaves the other kind's row alone (Q13)")+    }++    /// Q28: the reader's toggle is a *display* choice. Making it part of the+    /// row's identity would drop their ticks and strikes on every toggle, so the+    /// assembled kind stays the key and `displayedKind` carries the override.+    @Test("Reclassify writes the displayed kind and the previewed target, and keeps the row's key")+    func reclassifySetsTheDisplayedKindAndTarget() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Bay", quotes: ["Bay smiles"])]),+                      modelPhase: .seconds(1))++        let reclassified = ledger.reclassify(Self.proposalKey("Bay"), for: Self.workA,+                                             to: .place, target: Self.placeID)+        #expect(reclassified)++        let row = ledger.held(for: Self.workA).first+        #expect(row?.kind == .character, "identity is the kind the pass assembled it under")+        #expect(row?.displayedKind == .place)+        #expect(row?.target == .existing(Self.placeID))+        #expect(row?.facts.map(\.quote) == ["Bay smiles"], "the row's content is untouched")++        // Idempotent for the same kind, and reachable back the other way.+        let again = ledger.reclassify(Self.proposalKey("Bay"), for: Self.workA,+                                      to: .place, target: Self.placeID)+        #expect(again)+        #expect(ledger.held(for: Self.workA).first?.displayedKind == .place)++        let unknown = ledger.reclassify(Self.proposalKey("Bay", kind: .place), for: Self.workA,+                                        to: .character, target: nil)+        #expect(!unknown, "the key names the assembled kind, so a place key matches no row here")+    }++    @Test("A later settlement merges into a reclassified row without taking its target back")+    func mergedNeverInheritsATargetUnderTheAssembledKind() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([Self.proposal("Bay", quotes: ["Bay smiles"])]),+                      modelPhase: .seconds(1))+        _ = ledger.reclassify(Self.proposalKey("Bay"), for: Self.workA,+                              to: .place, target: Self.placeID)++        // The next source resolves the same key under the *assembled* kind and+        // finds a character to bundle onto. The reader's choice outranks it: a+        // merged row that inherited this target would commit a place decision+        // against a character (Q24, Q59).+        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("Bay", target: .existing(Self.characterID),+                          cites: [.genericNotes: "fp-g"], source: .genericNotes,+                          quotes: ["Bay leaves"], returnedKinds: [.character, .place]),+        ]), modelPhase: .seconds(1))++        let row = ledger.held(for: Self.workA).first+        #expect(ledger.held(for: Self.workA).count == 1)+        #expect(row?.displayedKind == .place, "the reader's override survives the merge")+        #expect(row?.target == .existing(Self.placeID))+        // Nothing either source contributed is lost, and the kinds the pass+        // returned the name under are unioned (Req 2.4's dual-kind skip).+        #expect(row?.facts.map(\.quote) == ["Bay leaves", "Bay smiles"])+        #expect(row?.returnedKinds == [.character, .place])+    }+     // MARK: - Reconcile invalidation (Req 2.8)      @Test("A deleted work takes its proposals and its attempt memory with it")@@ -333,14 +426,44 @@ struct CharacterExtractionLedgerTests {         ]), modelPhase: .seconds(1))          let invalidated = ledger.reconcile(against: [-            Self.workA: WorkExtractionState(revisions: [.entry(Self.entry1): "fp-1"],-                                            characterIDs: []),+            Self.workA: Self.state(),         ])          #expect(invalidated == [Self.workA])         #expect(ledger.held(for: Self.workA).map(\.name) == ["Grover"])     } +    @Test("A bundle is checked against the records of the kind it is displayed under")+    func reconcileChecksRecordIDsPerKind() {+        var ledger = Self.running()+        ledger.settle(Self.key(), .proposals([+            Self.proposal("Hanna", target: .existing(Self.characterID)),+            Self.proposal("Docks", kind: .place, target: .existing(Self.placeID)),+        ]), modelPhase: .seconds(1))++        // The place's id is in the character set and nowhere else: a bundle+        // resolved under one kind is never saved by a record of the other.+        let invalidated = ledger.reconcile(against: [+            Self.workA: Self.state(characters: [Self.characterID, Self.placeID]),+        ])++        #expect(invalidated == [Self.workA])+        #expect(ledger.held(for: Self.workA).map(\.name) == ["Hanna"])++        // And a reclassified row is judged under the kind it now displays.+        _ = ledger.reclassify(Self.proposalKey("Hanna"), for: Self.workA,+                              to: .place, target: Self.placeID)+        _ = ledger.reconcile(against: [+            Self.workA: Self.state(characters: [Self.characterID], places: [Self.placeID]),+        ])+        #expect(ledger.held(for: Self.workA).map(\.name) == ["Hanna"])++        _ = ledger.reconcile(against: [+            Self.workA: Self.state(characters: [Self.characterID, Self.placeID]),+        ])+        #expect(ledger.held(for: Self.workA).isEmpty)+    }+     @Test("A proposal whose cited revision changed under it is discarded, and its siblings are not")     func changedCorpusDropsOnlyWhatItTouched() {         var ledger = Self.running()@@ -352,9 +475,8 @@ struct CharacterExtractionLedgerTests {         ]), modelPhase: .seconds(1))          let invalidated = ledger.reconcile(against: [-            Self.workA: WorkExtractionState(-                revisions: [.entry(Self.entry1): "fp-1-edited", .genericNotes: "fp-g"],-                characterIDs: []),+            Self.workA: Self.state(+                revisions: [.entry(Self.entry1): "fp-1-edited", .genericNotes: "fp-g"]),         ])          #expect(invalidated == [Self.workA])@@ -367,7 +489,7 @@ struct CharacterExtractionLedgerTests {         ledger.settle(Self.key(), .proposals([Self.proposal("Hanna")]), modelPhase: .seconds(1))          _ = ledger.reconcile(against: [-            Self.workA: WorkExtractionState(revisions: [:], characterIDs: []),+            Self.workA: Self.state(revisions: [:]),         ])          #expect(ledger.held(for: Self.workA).isEmpty)@@ -379,8 +501,7 @@ struct CharacterExtractionLedgerTests {         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: []),+            Self.workA: Self.state(),         ])          #expect(invalidated.isEmpty)@@ -393,8 +514,7 @@ struct CharacterExtractionLedgerTests {         var ledger = Self.running()          let invalidated = ledger.reconcile(against: [-            Self.workA: WorkExtractionState(revisions: [.entry(Self.entry1): "fp-1-edited"],-                                            characterIDs: []),+            Self.workA: Self.state(revisions: [.entry(Self.entry1): "fp-1-edited"]),         ])          #expect(invalidated == [Self.workA])
Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift Modified +191 / -10
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swiftindex 1ddc899..7215313 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift@@ -388,8 +388,8 @@ struct CharacterGroundingTests {           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")+        let key = RecordNameKey.normalize(name)+        #expect(RecordNameKey.normalize(key) == key, "keying a key must change nothing")         #expect(key == key.trimmingCharacters(in: .whitespacesAndNewlines))     } @@ -398,21 +398,21 @@ struct CharacterGroundingTests {         // 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"))+        #expect(RecordNameKey.normalize("ILSE") == RecordNameKey.normalize("ilse"))+        #expect(RecordNameKey.normalize("Irmak") == RecordNameKey.normalize("IRMAK"))         // ...and the dotless ı stays a different name.-        #expect(CharacterNameKey.normalize("Irmak") != CharacterNameKey.normalize("ırmak"))+        #expect(RecordNameKey.normalize("Irmak") != RecordNameKey.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"))+        #expect(RecordNameKey.normalize("The Crowned One") == RecordNameKey.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")+        #expect(RecordNameKey.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")+        #expect(RecordNameKey.normalize("Theodore") == "theodore")+        #expect(RecordNameKey.normalize("The") == "the", "a bare article is a name, not a prefix")     }      // MARK: - Slash split (Decision 5, Q90)@@ -430,7 +430,7 @@ struct CharacterGroundingTests {         let candidate = outcome.candidates.first         #expect(candidate?.name == "Hanna")         #expect(candidate?.proposedAliases == ["Action Girl"])-        #expect(candidate?.nameKey == CharacterNameKey.normalize("Hanna"))+        #expect(candidate?.nameKey == RecordNameKey.normalize("Hanna"))     }      @Test("A multi-slash compound splits into the first name and every other component")@@ -493,4 +493,185 @@ struct CharacterGroundingTests {         #expect(outcome.candidates.first?.name == "Hanna")         #expect(outcome.candidates.first?.proposedAliases.isEmpty == true)     }++    // MARK: - Places (Req 1.2, Q42)++    /// One set of rules, run over both arrays. The only thing the place half+    /// does differently is its own cap (Q15) and the kind it tags the survivors+    /// with; the capital-letter rule in particular is kept unchanged (Q42),+    /// because the prototype measured it dropping 63 lowercase descriptions+    /// while keeping every proper-noun place.+    @Test("Places ground under the character rules and arrive tagged as places")+    func placesGroundUnderTheSameRules() {+        let text = """+        They meet in Tokyo, then drive out to Paradise Valley. The school is \+        closed and the hotel is full. Hanna waits.+        """+        let output = ExtractionResult(+            characters: [ExtractedCharacter(name: "Hanna")],+            places: [+                ExtractedPlace(name: "Tokyo", facts: [+                    ExtractedPlaceFact(statement: "is where they meet",+                                       quote: "They meet in Tokyo"),+                    ExtractedPlaceFact(statement: "burns", quote: "Tokyo burns"),+                ]),+                ExtractedPlace(name: "Paradise Valley"),+                ExtractedPlace(name: "school"),+                ExtractedPlace(name: "hotel"),+                ExtractedPlace(name: "it"),+            ])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        #expect(outcome.candidates.map(\.name) == ["Hanna", "Tokyo", "Paradise Valley"])+        #expect(outcome.candidates.map(\.kind) == [.character, .place, .place])+        let tokyo = outcome.candidates.first { $0.name == "Tokyo" }+        #expect(tokyo?.facts.map(\.quote) == ["They meet in Tokyo"])+        #expect(tokyo?.facts.allSatisfy { $0.nameKey == RecordNameKey.normalize("Tokyo") } == true)+        // Every rule the character half applies, applied here, and each drop+        // says which array it came out of (design §Diagnostics).+        #expect(outcome.drops == [+            GroundingDrop(name: "Tokyo", kind: .place, reason: .quoteNotVerbatim),+            GroundingDrop(name: "school", kind: .place, reason: .nameNeverCapitalised),+            GroundingDrop(name: "hotel", kind: .place, reason: .nameNeverCapitalised),+            GroundingDrop(name: "it", kind: .place, reason: .pronounName),+        ])+    }++    @Test("A slash-compound place splits into its name half and proposed aliases (Req 1.7)")+    func placeSlashSplit() {+        let text = "They land in Brockton Bay/the Bay and stay a week."+        let output = ExtractionResult(places: [ExtractedPlace(name: "Brockton Bay/the Bay")])++        let outcome = CharacterGrounding.ground(output, from: Self.source(text: text))++        let candidate = outcome.candidates.first+        #expect(candidate?.kind == .place)+        #expect(candidate?.name == "Brockton Bay")+        #expect(candidate?.proposedAliases == ["the Bay"])+        #expect(candidate?.nameKey == RecordNameKey.normalize("Brockton Bay"))+    }++    @Test("A place name with no cased letters is not held to the capital rule either")+    func uncasedPlacesPass() {+        let outcome = CharacterGrounding.ground(+            ExtractionResult(places: [ExtractedPlace(name: "서울")]),+            from: Self.source(text: "정교가 서울에서 太郎를 만났다."))++        #expect(outcome.candidates.map(\.kind) == [.place])+        #expect(outcome.drops.isEmpty)+    }++    @Test("Each kind is capped by its own constant, and an overflow drop names the kind")+    func perKindCaps() {+        let characterCount = CharacterExtractionBounds.maximumCandidates + 3+        let placeCount = CharacterExtractionBounds.maximumPlaceCandidates + 3+        let sentences = (0 ..< characterCount).map { "Char\($0) walks the long road." }+            + (0 ..< placeCount).map { "Place\($0) stands at the crossroads." }+        let output = ExtractionResult(+            characters: (0 ..< characterCount).map { ExtractedCharacter(name: "Char\($0)") },+            places: (0 ..< placeCount).map { ExtractedPlace(name: "Place\($0)") })++        let outcome = CharacterGrounding.ground(+            output, from: Self.source(text: sentences.joined(separator: " ")))++        #expect(outcome.candidates.filter { $0.kind == .character }.count+            == CharacterExtractionBounds.maximumCandidates)+        #expect(outcome.candidates.filter { $0.kind == .place }.count+            == CharacterExtractionBounds.maximumPlaceCandidates)+        // A place-heavy note may not spend the character cap, nor the reverse+        // (Q15) — the two counts are independent, and so are the overflows.+        #expect(outcome.drops.filter { $0.reason == .candidateCap }.map(\.kind)+            == Array(repeating: .character, count: 3) + Array(repeating: .place, count: 3))+    }++    // MARK: - The prototype's synthetic probes (Req 1.8)++    /// One of the five probe notes `prototype/Sources/main.swift` runs, with the+    /// names the 2026-09-10 run returned for it and the outcome grounding gave+    /// each (`prototype/prototype-findings.md` §Synthetic probes). They are Req+    /// 1.8's capital-letter evidence, pinned here so the rule cannot move+    /// without the record of what it did moving with it: a description that+    /// opens a sentence carries a capital and is kept ("Hotel", "School",+    /// "Apartment next door"), a lowercase one is dropped ("school",+    /// "bathroom", "Library").+    ///+    /// Probe 3 is absent: its combined request was refused by the guardrails, so+    /// that run produced no output to ground.+    ///+    /// The names and the keep/drop outcomes are the run's; the quotes are not+    /// (the findings record fact *counts*), so the probes carry names only.+    struct Probe: Sendable {+        var id: String+        var text: String+        var output: ExtractionResult+        var keptCharacters: [String]+        var keptPlaces: [String]+        var drops: [GroundingDrop]+    }++    static func probeOutput(characters: [String], places: [String]) -> ExtractionResult {+        ExtractionResult(characters: characters.map { ExtractedCharacter(name: $0) },+                         places: places.map { ExtractedPlace(name: $0) })+    }++    static let probes: [Probe] = [+        Probe(id: "synthetic-1",+              text: """+              Alex and Willow end up at the hotel in Tokyo again. Lots of sex in this one, which the author \+              handles fine but it drags. Jack calls from the Pentagon about the silicates. Terawatt shows up \+              at the end and the fight is short.+              """,+              output: probeOutput(characters: ["Alex Mack", "Willow", "Jack", "Terawatt"],+                                  places: ["Tokyo"]),+              keptCharacters: ["Willow", "Jack", "Terawatt"],+              keptPlaces: ["Tokyo"],+              drops: [GroundingDrop(name: "Alex Mack", kind: .character, reason: .nameNotInSource)]),+        Probe(id: "synthetic-2",+              text: """+              Taylor goes to the school and gets cornered by Emma and Sophia in the bathroom. Brockton Bay \+              is as grim as ever. Then dinner at the Dallons, Amy being awkward about it. Armsmaster on the \+              news.+              """,+              output: probeOutput(+                  characters: ["Taylor", "Emma", "Sophia", "Brockton Bay", "Amy", "Armsmaster",+                               "Dallons"],+                  places: ["school", "bathroom", "Brockton Bay", "Dallons"]),+              keptCharacters: ["Taylor", "Emma", "Sophia", "Brockton Bay", "Amy", "Armsmaster",+                               "Dallons"],+              keptPlaces: ["Brockton Bay", "Dallons"],+              drops: [GroundingDrop(name: "school", kind: .place, reason: .nameNeverCapitalised),+                      GroundingDrop(name: "bathroom", kind: .place, reason: .nameNeverCapitalised)]),+        Probe(id: "synthetic-4",+              text: """+              Hotel was where most of this chapter happened. Alex sneaks out past the lobby while Willow keeps \+              watch. Apartment next door is empty. Later they drive to Paradise Valley to meet Jack.+              """,+              output: probeOutput(characters: ["Alex Mack", "Willow", "Jack"],+                                  places: ["Hotel", "Paradise Valley", "Apartment next door"]),+              keptCharacters: ["Willow", "Jack"],+              keptPlaces: ["Hotel", "Paradise Valley", "Apartment next door"],+              drops: [GroundingDrop(name: "Alex Mack", kind: .character, reason: .nameNotInSource)]),+        Probe(id: "synthetic-5",+              text: """+              School again. Taylor hides in the library until Sophia leaves. Bathroom scene is short this \+              time. The Boardwalk at night, then the Docks, where Lisa finds her.+              """,+              output: probeOutput(characters: ["Taylor", "Sophia", "Lisa"],+                                  places: ["School", "Library", "Boardwalk", "Docks"]),+              keptCharacters: ["Taylor", "Sophia", "Lisa"],+              keptPlaces: ["School", "Boardwalk", "Docks"],+              drops: [GroundingDrop(name: "Library", kind: .place, reason: .nameNeverCapitalised)]),+    ]++    @Test("The prototype's probes ground to exactly what that run recorded", arguments: probes)+    func syntheticProbes(probe: Probe) {+        let outcome = CharacterGrounding.ground(probe.output, from: Self.source(text: probe.text))++        #expect(outcome.candidates.filter { $0.kind == .character }.map(\.name)+            == probe.keptCharacters, "\(probe.id) characters")+        #expect(outcome.candidates.filter { $0.kind == .place }.map(\.name)+            == probe.keptPlaces, "\(probe.id) places")+        #expect(outcome.drops == probe.drops, "\(probe.id) drops")+    } }
Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift Modified +43 / -2
diff --git a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swiftindex a93d59d..24c76e6 100644--- a/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift+++ b/Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift@@ -66,6 +66,45 @@ struct FoundationCharacterExtractionModelClientTests {         #expect(instructions.contains("\"sex\""))     } +    // MARK: - Places in the one request (Req 1.1, Q43, Q62)++    /// The shipped text is the prototype's arm B, and the numbers in+    /// `prototype/prototype-findings.md` are that text's (Q62). These assertions+    /// pin the three things that arm changed, so a reword has to reckon with the+    /// run that measured Req 1.8 against it.+    @Test("The instructions ask for named places and say what is not one")+    func instructionsNamePlaces() {+        let instructions = FoundationCharacterExtractionModelClient.instructions++        #expect(instructions.contains("named story characters and named places"))+        #expect(instructions.contains(+            "A place is a location in the story that the note refers to by a proper name"))+        // Q10: proper nouns at any scale, and a description is not one.+        #expect(instructions.contains("a world, a country, a city, a district, a building"))+        #expect(instructions.contains("her apartment"))+        #expect(instructions.contains("return an empty list of places"))+    }++    @Test("The instructions admit a capitalised title or epithet as a name (Q43)")+    func instructionsAdmitEpithets() {+        let instructions = FoundationCharacterExtractionModelClient.instructions++        #expect(instructions.contains(+            "A title or epithet the note uses as a name, written with a capital letter"))+        #expect(instructions.contains("The Crowned One"))+        // The closing rule covers both kinds now.+        #expect(instructions.contains(+            "A character or place the note only mentions by name is reported with no facts"))+    }++    @Test("The prompt closes by asking for both kinds")+    func promptAsksForBothKinds() {+        let prompt = FoundationCharacterExtractionModelClient.prompt(for: Self.source)++        #expect(prompt.contains(+            "List the named story characters and the named places this note mentions"))+    }+     // 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")@@ -127,7 +166,7 @@ struct FoundationCharacterExtractionModelClientTests {         return true     } -    @Test("A live model call returns a decodable ExtractionResult for one note")+    @Test("A live model call returns a decodable ExtractionResult, places included, for one note")     func liveCall() async throws {         let client = FoundationCharacterExtractionModelClient()         try await withKnownIssue("The on-device model is unavailable on this host") {@@ -148,8 +187,10 @@ struct FoundationCharacterExtractionModelClientTests {                  // 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.+                // into the structure at all, both arrays of it (Q26: a response+                // that cannot be decoded in full is a failed attempt).                 #expect(result.characters.allSatisfy { !$0.name.isEmpty })+                #expect(result.places.allSatisfy { !$0.name.isEmpty })             } matching: { issue in                 Self.isTransientModelError(issue.error)             }
docs/agent-notes/rule-wire-format.md Modified +6 / -4
diff --git a/docs/agent-notes/rule-wire-format.md b/docs/agent-notes/rule-wire-format.mdindex 242b6ef..411b7a9 100644--- a/docs/agent-notes/rule-wire-format.md+++ b/docs/agent-notes/rule-wire-format.md@@ -6,9 +6,9 @@ They reach persistence twice, by different routes: - **The store**: JSON in an opaque column — `URLRulePattern.definitionData`, and   `TitlePattern`'s decoded columns. Mirrored to CloudKit as bytes, so no schema change is   involved and no schema version protects it.-- **The archive**: the *typed* value inside `BackupV11URLRule.definition` /-  `BackupV11TitlePattern.definition` — the live 11/12 wire substrate — re-encoded-  and checksummed by `BackupV11Codec`. The record types are renamed with each+- **The archive**: the *typed* value inside `BackupV12URLRule.definition` /+  `BackupV12TitlePattern.definition` — the live 12/13 wire substrate — re-encoded+  and checksummed by `BackupV12Codec`. The record types are renamed with each   generation, so a note naming `BackupV4*`/`BackupV6Codec` is describing a build   several generations back. **The rename is not evidence that anything on this   page changed**: 9/10 (`work-and-reading-status`) renamed the whole `BackupV8*`@@ -17,7 +17,9 @@ They reach persistence twice, by different routes:   `Work` columns and two new record types (`BackupV10Series`, `BackupV10Link`),   and 11/12 (`work-creators`) renamed the `BackupV10*` set for three more   (`BackupV11Creator`, `BackupV11CreatorRole`, `BackupV11Credit`) and **no `Work`-  column at all**. No rule definition moved in any of them. Read the+  column at all**, and 12/13 (`place-extraction`) renamed the `BackupV11*` set+  for two more (`BackupV12Place`, `BackupV12PlaceSuppression`). No rule+  definition moved in any of them. Read the   generation as "which build wrote it", not as "which rule forms it can carry".  So a change to one of these types has two independent compatibility stories, and the
docs/agent-notes/schema-migration.md Modified +185 / -101
diff --git a/docs/agent-notes/schema-migration.md b/docs/agent-notes/schema-migration.mdindex 5a28f00..a9d0b0c 100644--- a/docs/agent-notes/schema-migration.md+++ b/docs/agent-notes/schema-migration.md@@ -1,6 +1,6 @@ # Schema migration -Schema **V12** is live (since `specs/work-creators/`), with **V11**+Schema **V13** is live (since `specs/place-extraction/`), with **V12** frozen beside it as the `from` side of one lightweight stage. The app opens `openForApp` and the share extension `openForExtension` — one opener per role, both over the same schema and the same file layout (`retire-migration-chain`'s@@ -10,22 +10,52 @@ 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 AsterismSchemaV12 { @Model final class Entry … }`+  classes live in `extension AsterismSchemaV13 { @Model final class Entry … }`   (`Models.swift`) and are reached by top-level typealiases-  (`typealias Entry = AsterismSchemaV12.Entry`). `AsterismSchemaV11.swift` holds+  (`typealias Entry = AsterismSchemaV13.Entry`). `AsterismSchemaV12.swift` holds   the one frozen snapshot — stored columns and   `@Relationship` macros only, `public init() {}`, no accessors. The nesting is   what makes that snapshot legal; keep it.-- **`AsterismV12MigrationPlan` = `[V11, V12]`, one lightweight stage, and it-  only *adds*.** V12 is V11 plus three new tables — `Creator`, `CreatorRole` and-  `WorkCredit` (`work-creators`). It is the first stage in the project's history+- **`AsterismV13MigrationPlan` = `[V12, V13]`, one lightweight stage, and it+  only *adds*.** V13 is V12 plus two new tables — `Place` and+  `PlaceSuppression` (`place-extraction`). It is the **second** stage in a row   that adds **only** tables: no `Work` column is added, so there is not even an   attribute default involved, no existing column changes type, no relationship-  changes shape, and none of the three new tables declares a relationship at-  all. The entity list grows from twelve to fifteen.--  **The two directory tables converge through one generalised fold, and that-  is a schema fact as much as a code one.** `Creator` and `CreatorRole` copy+  changes shape, and neither new table declares a relationship at all. The+  entity list grows from fifteen to seventeen.++  **A place addresses its work by UUID column where a character uses a+  relationship, and that asymmetry is deliberate.** `Character` and+  `CharacterSuppression` are the *only* tables added since V6 that carry a+  `@Relationship` — V7's, predating the standing rule — and V13 did not copy+  them: `Place.workID` and `PlaceSuppression.workID` are plain columns, per+  `CLAUDE.md`'s "a cross-entity reference is a UUID column". The consequence+  reaches the generic store code below: a table reached through an inverse and+  a table reached by column cannot be fetched the same way.++  **The store's record code is generic over `RecordRow`, and the protocol owns+  every fetch** (`RecordRow.swift`, `place-extraction` Decision 3). Groups,+  authored content, repointing, ranking, the read-side presentations and the+  archive merge are written once against the protocol and conformed twice, by+  `Character` and by `Place`. Two rules come out of that and both are load-bearing+  for the next table:++  - **`recordID`, never `id`.** `PersistentModel` already vends+    `id: PersistentIdentifier`, so a generic `row.id` is ambiguous and resolves+    to the wrong thing. The protocol names the application UUID `recordID`, and+    every conformance vends it from its own `id` column. Proved by the+    `generic-store-spike` under `specs/place-extraction/prototype/`, not+    guessed.+  - **A `#Predicate` cannot be written against a protocol-typed key path**, so+    the generic code writes none. It asks the row type for the rows of some+    works or of some record ids (`rows(of:context:)`, `rows(ids:context:)`) and+    each conformance answers the way that table must be read — the character+    tables through their inverse, a column-keyed table by predicate. A table+    reached through an inverse must also deduplicate by object identity, because+    one record reached through two rows of a work's identity group is one record.++  **V12 added the two directory tables, and they converge through one generalised+  fold — a schema fact as much as a code one.** `Creator` and `CreatorRole` copy   `WorkTypeEntity`'s shape — defaulted non-optional columns, no relationships,   one timestamp per independently converging field, `Date(timeIntervalSince1970: 0)`   as the pristine sentinel — and the per-field election that reads them is@@ -38,14 +68,23 @@ background for the *next* schema bump and describes states that no longer exist.   on the `WorkLink` shape, addressing its work and its creator by identifier,   and it converges through `CreditReconciler` instead. -  **The V10 → V11 stage retired in the same commit as the freeze**, on+  **The V11 → V12 stage retired in the same commit as the freeze**, on   `retire-migration-chain` Decision 6's population precondition: the owner-  confirmed every device on marker `"11"` on 2026-09-07 (`work-creators` Q15,-  the `prerequisites.md` box) *before* phase 1 ran, so `AsterismSchemaV10.swift`,-  `V10RecordedStoreFixture` and `V10RecordedStoreTests` went with it. Unlike the-  two bumps before it, the verification landed ahead of the freeze rather than a-  commit behind it, so the marker set and the plan have never disagreed at this-  generation.+  confirmed every device on marker `"12"` on 2026-09-10 (`place-extraction`+  `prerequisites.md`) *before* the freeze ran, so `AsterismSchemaV11.swift`,+  `V11RecordedStoreFixture` and `V11RecordedStoreTests` went with it, along with+  every `Schema(versionedSchema:)` reference that named V11 (`place-extraction`+  Q65 — the rename lands in the *freeze* task, because the test target must+  compile at every green commit). That is the second bump in a row with the+  verification ahead of the freeze rather than a commit behind it.++  **The V10 → V11 stage retired in the same commit as its freeze too**, on the+  same precondition: the owner confirmed every device on marker `"11"` on+  2026-09-07 (`work-creators` Q15, the `prerequisites.md` box) *before* phase 1+  ran, so `AsterismSchemaV10.swift`, `V10RecordedStoreFixture` and+  `V10RecordedStoreTests` went with it. Unlike the two bumps before it, the+  verification landed ahead of the freeze rather than a commit behind it, so the+  marker set and the plan have never disagreed at either generation.    **The V9 → V10 stage retired one commit late** (Q32, then Q60 of   `series-and-related-works`). The design retired it with the freeze, on@@ -65,7 +104,7 @@ background for the *next* schema bump and describes states that no longer exist.   behind it. - **V10 was V9 plus three defaulted `Work` columns** — `workStatusRaw`,   `readingStatusRaw` and `verdict` (`work-and-reading-status`). That stage is-  gone, and so is its snapshot, but its columns are part of the frozen V11 shape+  gone, and so is its snapshot, but its columns are part of the frozen V12 shape   and its defaults are bytes in every installed library.   It remains the only version in the project's history to add a **non-optional**   scalar to an existing table under a bare lightweight stage. The property@@ -75,27 +114,29 @@ background for the *next* schema bump and describes states that no longer exist.   assert the **raw column** after conversion for the reason   `V10RecordedStoreTests` did: a `ToleratedEnum.read(_, default:)` accessor   answers `.ongoing` whether or not the default ever landed, so asserting-  through it would prove nothing. `V11RecordedStoreTests` measures the current-  stage — three tables arriving empty, and the whole live library unchanged+  through it would prove nothing. `V12RecordedStoreTests` measures the current+  stage — two tables arriving empty, and the whole live library unchanged   field by field — and `V4RecordedStoreTests` is the below-floor refusal suite.-  `ModelContractTests` pins every half of the shape: the twelve entities V11-  declares are the first twelve of the fifteen V12 declares; `Work`'s columns-  are *identical* in `Schema(versionedSchema: AsterismSchemaV11.self)` and in-  the V12 schema, which is what makes this bump the tables-only one it claims to+  `ModelContractTests` pins every half of the shape: the fifteen entities V12+  declares are the first fifteen of the seventeen V13 declares; `Work`'s columns+  are *identical* in `Schema(versionedSchema: AsterismSchemaV12.self)` and in+  the V13 schema, which is what makes this bump the tables-only one it claims to   be. The "and absent from the previous snapshot" half of the V10 and V11 column   pins went with `AsterismSchemaV10` (`work-creators` Q15): there is no frozen-  snapshot below V11 left to compare against. It still pins that none of V9's-  dropped names is in `Schema(...).entities`, and that the new tables are-  CloudKit-legal — every property defaulted, nothing unique, no relationship on-  any of them.+  snapshot below the current one left to compare against. It still pins that+  none of V9's dropped names is in `Schema(...).entities`, and that the new+  tables are CloudKit-legal — every property defaulted, nothing unique, no+  relationship on either of them.   **The V8 → V9 stage is retired** (Q18): every device was confirmed at marker   `"9"` on 2026-09-04 — `retire-migration-chain` Decision 6's population   precondition — so `AsterismSchemaV8`, `AsterismV9MigrationPlan` and the   `V8RecordedStore*` pair went with it, exactly as `AsterismSchemaV5/V6/V7` went   at V9, and exactly as `AsterismSchemaV9` and the `V9RecordedStore*` pair went   at V11 on the same precondition (Q60), and exactly as `AsterismSchemaV10` and-  the `V10RecordedStore*` pair went at V12 (`work-creators` Q15). A store below-  V11 fails closed with `NSCocoaErrorDomain` 134504 and the recovery is the+  the `V10RecordedStore*` pair went at V12 (`work-creators` Q15), and exactly as+  `AsterismSchemaV11` and the `V11RecordedStore*` pair went at V13+  (`place-extraction` Q65). A store below+  V12 fails closed with `NSCocoaErrorDomain` 134504 and the recovery is the   backup archive.   **What V9's removing stage cost is now history, and the new hazard is the   opposite one.** V9 dropped 36 columns and the `Site.works` ↔ `Work.site`@@ -111,7 +152,7 @@ background for the *next* schema bump and describes states that no longer exist.   shown or filtered, while **export refuses the same value by name** — reading   is tolerant, writing is not (`Models.swift`'s `ToleratedEnum` policy; Reqs   1.3, 2.7, 8.3). The raw spellings are frozen — they are bytes in every-  installed library and defaults in the frozen V11 snapshot;+  installed library and defaults in the frozen V12 snapshot;   a committed edit writes what the picker shows, so an unknown   raw does not survive an unrelated edit (Q19). - **No production data pass, and no `LegacyColumns`.** V8's columns were@@ -146,7 +187,7 @@ background for the *next* schema bump and describes states that no longer exist.   below the plan's floor before any container exists; the recovery is the backup   archive), but **every test seeding a store older than the plan's floor and then   opening it has to be rewritten at every bump.** The one convertible fixture is-  `V11RecordedStoreFixture`, seeded in-process+  `V12RecordedStoreFixture`, seeded in-process   through the snapshot it names; `v4-recorded-4.0.0.sqlite` survives only as the   one input that positively reads *below* the floor for the classifier suites.   **This is what happened at V10**, exactly as this note predicted it would:@@ -157,8 +198,10 @@ background for the *next* schema bump and describes states that no longer exist.   **And it happened again at V11**, exactly that way: the follow-up that deleted   `AsterismSchemaV9` deleted `V9RecordedStoreFixture` and `V9RecordedStoreTests`   in the same commit (Q60). **V12 did it deliberately in one commit**, the-  freeze and the retirement together (`work-creators` Q15). Do the same at V13:-  seed the successor fixture through the *then*-frozen V12 snapshot, and delete+  freeze and the retirement together (`work-creators` Q15), and **V13 did the+  same** (`place-extraction` Q65), with the rename of every test+  `Schema(versionedSchema:)` reference in that commit too. Do the same at V14:+  seed the successor fixture through the *then*-frozen V13 snapshot, and delete   its predecessor in the commit that deletes the schema it opened. - The P1 probe's other measurements still stand   (`specs/retire-migration-chain/probe-result.md`, **PASS**): a store that had@@ -194,52 +237,59 @@ background for the *next* schema bump and describes states that no longer exist.   The V3 → V4 sidecar and completion pass (`V4Migration.buildSidecar` /   `runCompletionPass`) are deleted; the sidecar *filename* survives because the   classifier reads its presence to refuse an open over a vanished store (Q19).-- **The readiness marker holds `"12"`, and the app opens two generations.**-  `extensionOpenableMarkerVersion` is `"12"` — the only one the extension opens+- **The readiness marker holds `"13"`, and the app opens two generations.**+  `extensionOpenableMarkerVersion` is `"13"` — the only one the extension opens   and the only one `publishReadiness` writes — while-  `appOpenableMarkerVersions` is `["11", "12"]` (`laggingOpenableMarkerVersion`-  is `"11"`). An *empty* store is marked ready at birth (Q26). A store carrying+  `appOpenableMarkerVersions` is `["12", "13"]` (`laggingOpenableMarkerVersion`+  is `"12"`). An *empty* store is marked ready at birth (Q26). A store carrying   any other value is refused, with it named in the message, and the recovery is   the backup archive.    **Both generations the app opens are spelled with two characters.** Nothing   anywhere may assume a marker is one character long — not a parser, not a test   fixture, not a comparison. `MarkerContractTests` and-  `MarkerGenerationTwelveTests` are where that is pinned.+  `MarkerGenerationThirteenTests` are where that is pinned. -  **V12 substitutes rather than adds**: `"10"` is gone and `"11"` took its place.+  **V13 substitutes rather than adds**: `"11"` is gone and `"12"` took its place.   The table below says that is only defensible after re-verifying the whole   population has passed the old digit — and **at this bump the verification came-  first**: the owner confirmed every device on marker `"11"` on 2026-09-07-  (`work-creators` Q15) before the freeze, so the marker set and-  `AsterismV12MigrationPlan` moved together. The bump before it did the opposite-  (Q32, then Q60 of `series-and-related-works`), and kept the V9 → V10 stage-  instead — which does not help a device on marker `"9"`, because the marker-  check refuses it before any container exists. Read that gap as a warning-  rather than a precedent.-  A `"12"` generation exists at all for a stage with no data pass because+  first** again: the owner confirmed every device on marker `"12"` on 2026-09-10+  (`place-extraction` `prerequisites.md`) before the freeze, so the marker set+  and `AsterismV13MigrationPlan` moved together, exactly as `"11"` on 2026-09-07+  and `AsterismV12MigrationPlan` did (`work-creators` Q15). Two bumps before+  that one did the opposite (Q32, then Q60 of `series-and-related-works`), and+  kept the V9 → V10 stage instead — which does not help a device on marker+  `"9"`, because the marker check refuses it before any container exists. Read+  that gap as a warning rather than a precedent.+  A `"13"` generation exists at all for a stage with no data pass because   `openContainer` passes the migration plan for **both** roles, so the marker   check is the only thing keeping the stage out of the share extension (Q3). -  `BootstrapState.markerLagging` classifies an `"11"` store, and `act(on:)` runs:-  open (which adds the three empty tables) →-  `validateStore` → `publishReadiness` (writes `"12"`) →+  `BootstrapState.markerLagging` classifies a `"12"` store, and `act(on:)` runs:+  open (which adds the two empty tables) →+  `validateStore` → `publishReadiness` (writes `"13"`) →   `clearResidualEvidence`. **No data pass and no reconciler**, on the V9 arm's   own grounds (Q9 of `drop-superseded-columns`): V8's arm ran both because V8-  *added tables and blobs* something had to fill, whereas V12's new tables start+  *added tables and blobs* something had to fill, whereas V13's new tables start   empty and it adds no column at all — there is nothing to fill. What certifies   the conversion is the store validating, so validation is the gate and the-  marker goes after it — a throw leaves `"11"` on disk and the next open+  marker goes after it — a throw leaves `"12"` on disk and the next open   re-enters the arm over an already-converted store, which is safe because adding   tables that are already there is a no-op. -  The extension's refusal **forks**: `"11"` gets "Open Asterism to finish+  **The generation is the case's payload, not a case of its own.**+  `markerLagging(generation:)` carries `"12"`; the per-generation cases+  `markerLaggingV4/V5/V6` were retired by `data-model-cleanups` Decision 2, so a+  bump moves the two constants and the arm's wording and adds no+  `BootstrapState` case at all (`place-extraction` Q64).++  The extension's refusal **forks**: `"12"` gets "Open Asterism to finish   updating the library", anything else gets the shipped "has not initialized"   wording (`work-creators` Req 11.3).    The writer is `publishReadiness`, deliberately unversioned: it always writes-  the current generation, and the generation has moved eight times (4 → 5 → 6 →-  7 → 8 → 9 → 10 → 11 → 12), as the section below records. A nonempty+  the current generation, and the generation has moved nine times (4 → 5 → 6 →+  7 → 8 → 9 → 10 → 11 → 12 → 13), as the section below records. A nonempty   unmarked store fails the open naming the state; an empty one is marked and   opened — where "empty" means `LibraryRecordCounts.holdsNoReaderRecords`, which   excludes the seeded work-type rows the app writes itself.@@ -257,34 +307,64 @@ background for the *next* schema bump and describes states that no longer exist.   comment where it stood). "Work-only" is derived, not stored:   `Site.isWorkOnlyTitleRule` is true when the active pattern is `.wholeTitle`. - **Capability gate is `.multiSite`** (`AsterismCapabilities.current`,-  `multi-site-works` Q29). `BackupV11Codec` stamps the literal `"multi-site"`+  `multi-site-works` Q29). `BackupV12Codec` stamps the literal `"multi-site"`   rather than reading `current`, so the archive's gate is independent of the-  runtime's. 8/9 through 11/12 all kept the literal (`rule-citation-by-uuid` Q19):+  runtime's. 8/9 through 12/13 all kept the literal (`rule-citation-by-uuid` Q19):   the gate names a store shape and a rule-form set, and neither changed. No `supports…` answer   changed with the gate — every one is m4's — so the case exists to name the   store shape it belongs to, and the *generation* is named by its format/schema   numbers, which is what the importer gates on.-- **Backup writes and reads 11/12 only** (the `data-model-cleanups` Decision 2+- **Backup writes and reads 12/13 only** (the `data-model-cleanups` Decision 2   argument, made again: single-user population, fully migrated).-  `BackupV11Exporter` is the only exporter and `BackupImporter.plan` accepts only-  `supportedVersions` — `(BackupV11Document.formatVersion,-  BackupV11Document.schemaVersion)`, i.e. `(11, 12)` — with any other pair refused+  `BackupV12Exporter` is the only exporter and `BackupImporter.plan` accepts only+  `supportedVersions` — `(BackupV12Document.formatVersion,+  BackupV12Document.schemaVersion)`, i.e. `(12, 13)` — with any other pair refused   by version check, naming the detected pair, not by decode failure. The archive-  format number is not the schema number: 11/12 is format 11 over schema 12, and+  format number is not the schema number: 12/13 is format 12 over schema 13, and   since `rule-citation-by-uuid` Q9 the schema number names the *store* schema-  the archive was taken from. Every older import path — 2/2, 3/3, 4/4, 5/6, 6/7,-  7/8, 8/9, 9/10, 10/11 — is **deleted**; recovering an older archive means-  checking out a build that still carries its importer. **An archive of the-  previous generation, exported before this build, is unreadable by it**-  (`work-and-reading-status` Q17): the restorable archive is one exported+  the archive was taken from — which is why `BackupV12Document.schemaVersion` is+  pinned to the live schema by a test rather than to a literal someone has to+  remember (`place-extraction` Q66). Every older import path — 2/2, 3/3, 4/4,+  5/6, 6/7, 7/8, 8/9, 9/10, 10/11, 11/12 — is **deleted**; recovering an older+  archive means checking out a build that still carries its importer. **An+  archive of the previous generation, exported before this build, is unreadable+  by it** (`work-and-reading-status` Q17): the restorable archive is one exported   *after* upgrading. The historically-named V4/V5   *record types* (`BackupV4Entry`, `BackupV5Work`, …) that used to be the-  payload's wire substrate are deleted too: the payload is `BackupV11Payload`-  over `BackupV11Work`, `BackupV11Entry`, `BackupV11Membership`,-  `BackupV11DistinctPair` and the rest, all named for the format that carries+  payload's wire substrate are deleted too: the payload is `BackupV12Payload`+  over `BackupV12Work`, `BackupV12Entry`, `BackupV12Membership`,+  `BackupV12DistinctPair` and the rest, all named for the format that carries   them. `LegacyV2DateFormatter` and   `DuplicateJSONKeyValidator` live on in `BackupJSONCodecSupport.swift`; the live   codec uses both.++  **12/13 added two arrays, `places` and `placeSuppressions`**, over+  `BackupV12Place` and `BackupV12PlaceSuppression` — separate arrays rather than+  a kind column on the character ones, because the store keeps the two kinds in+  separate tables and a shared array would be a second spelling of that split.+  Both are enumerated whole, and for the places there is a second reason: a+  `Place` declares no relationship at all, so an orphan is reachable only that+  way. The merge is the generic `mergeImportedRecords<Row>` /+  `mergeImportedSuppressions<Row>`, called once per kind.++  **One place-only import rule, and it is easy to get wrong** (`place-extraction`+  Q76). On the **update** path an archived place's `workID` is adopted only when+  it resolves in this library or the local row is itself an orphan; the+  **insert** path takes it as-is. Ownership is not timestamped — a work merge+  moves `workID` without touching `modifiedAt` — so the `modifiedAt` value guard+  cannot protect it, and the codec deliberately admits a place naming a work the+  payload lacks. Without the rule, re-importing an archive taken *before* a work+  merge moves a place onto an absent work and it disappears from every screen.+  The character side cannot reach this, because it owns a relationship and+  `attach(to:archivedWorkID:)` leaves an existing one alone; the place rule is+  that same intent stated over a column. **A new table reached by a UUID column+  owes the same rule.**++  Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1`+  mode, never by hand: `backup-12-13-golden.json` replaced the 11/12 file, seeded+  with two places — one of them naming a work the payload does not carry, which+  is the orphan shape — and two place suppressions, one of them carrying a+  `sourceEntryID`. - **`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@@ -300,13 +380,13 @@ 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 (V12 and later)+## Adding a schema version (V13 and later) -V11 → V12 is the freshest worked example, and the only one that has ever added-**nothing but tables**: `AsterismSchemaV11.swift` (the snapshot frozen by-`work-creators`), `AsterismSchemaV12.swift` (live schema plus the plan), the-suite that measures the conversion (`V11RecordedStoreTests` over-`V11RecordedStoreFixture`) and the one that refuses anything older+V12 → V13 is the freshest worked example, and the **second** in a row to add+nothing but tables: `AsterismSchemaV12.swift` (the snapshot frozen by+`place-extraction`), `AsterismSchemaV13.swift` (live schema plus the plan), the+suite that measures the conversion (`V12RecordedStoreTests` over+`V12RecordedStoreFixture`) and the one that refuses anything older (`V4RecordedStoreTests`). V10 → V11 remains the only stage that has added optional columns, V9 → V10 the only one that has added a non-optional scalar, and V8 → V9 the only one that has ever *removed* anything. What a new version@@ -314,14 +394,14 @@ has to touch:  | Step | Where | |---|---|-| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV12` proper — stored columns and `@Relationship` macros only, `public init() {}`, no accessors — and add `AsterismSchemaV13` with the new models; every entity nested, zero top-level `@Model`. Name in the frozen header every enum raw value its defaults bake in, as V11's header names `WorkStatus.ongoing` and `ReadingStatus.reading` |-| Add the stage | `AsterismV12MigrationPlan`'s successor: `.lightweight(fromVersion: V12, toVersion: V13)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |-| Give an added column a literal default, or make it optional | A defaulted, non-optional, non-unique scalar is the CloudKit-mirrored shape most columns here have, and its **property initialiser is what becomes the Core Data attribute default** — which is what fills existing rows during the stage. An **optional** column needs no default at all, which is what V11's two `Work` columns did: nil is the value, and there is nothing for the stage to write. A stage that adds only *tables*, as V12 does, involves neither. Either way, assert the **raw column** after conversion, not an accessor that would answer the default either way (`V11RecordedStoreTests` is the current suite; `V10RecordedStoreTests` was the worked example for a column) |-| Extend the accepted markers | `appOpenableMarkerVersions`, `laggingOpenableMarkerVersion` and `extensionOpenableMarkerVersion` (`"12"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit — `work-and-reading-status` Q18 and `drop-superseded-columns` Q2 are what that verification looks like written down, and `series-and-related-works` Q32 is what it looks like when it is *skipped*: keeping the old stage in the plan does not compensate, because the marker check refuses first, and `work-creators` Q15 is what it looks like done in the right order — the box ticked before phase 1. The generation is a *string*, not a digit: `"11"` and `"12"` both have two characters. **Check what the suites use as their canonical *unrecognised* marker before taking the next value**: five suites used `"10"` for that, and it had to move to `"99"` when `"10"` went live, or they would have been asserting the refusal of a marker the app opens (Q28) |-| Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness; a new marker generation needs a case there and an action beside it, not a guard inside the open |-| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`work-creators` is the live worked example**: `BootstrapState.markerLagging` plus the `"11"` arm in `act(on:)`, with `MarkerGenerationTwelveTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the generation, and validation is what certifies it |+| Declare the snapshot | Freeze the current live schema as `AsterismSchemaV13` proper — stored columns and `@Relationship` macros only, `public init() {}`, no accessors — and add `AsterismSchemaV14` with the new models; every entity nested, zero top-level `@Model`. Name in the frozen header every enum raw value its defaults bake in, as V12's header names `WorkStatus.ongoing` and `ReadingStatus.reading`. Rename every test `Schema(versionedSchema:)` reference in **this** commit: the snapshot the old ones name is deleted here, and the test target must compile at every green commit (`place-extraction` Q65) |+| Add the stage | `AsterismV13MigrationPlan`'s successor: `.lightweight(fromVersion: V13, toVersion: V14)`, or a data pass run by the bootstrap if the change is not purely structural. Declaring it makes every store older than the plan's oldest schema **fail closed** — see the current-state bullet, and rewrite the fixtures that seed one |+| Give an added column a literal default, or make it optional | A defaulted, non-optional, non-unique scalar is the CloudKit-mirrored shape most columns here have, and its **property initialiser is what becomes the Core Data attribute default** — which is what fills existing rows during the stage. An **optional** column needs no default at all, which is what V11's two `Work` columns did: nil is the value, and there is nothing for the stage to write. A stage that adds only *tables*, as V12 and V13 both do, involves neither. Either way, assert the **raw column** after conversion, not an accessor that would answer the default either way (`V12RecordedStoreTests` is the current suite; `V10RecordedStoreTests` was the worked example for a column) |+| Extend the accepted markers | `appOpenableMarkerVersions`, `laggingOpenableMarkerVersion` and `extensionOpenableMarkerVersion` (`"13"`, what `publishReadiness` writes) in `LibraryRepository+Bootstrap.swift`. **Both roles.** **Add** the new generation to the app's set rather than substituting, or every device that has not launched the new build yet fails closed. Substituting is only defensible after re-verifying the whole population has passed the old digit — `work-and-reading-status` Q18 and `drop-superseded-columns` Q2 are what that verification looks like written down, and `series-and-related-works` Q32 is what it looks like when it is *skipped*: keeping the old stage in the plan does not compensate, because the marker check refuses first, and `work-creators` Q15 and `place-extraction`'s `prerequisites.md` are what it looks like done in the right order — the box ticked before the freeze. The generation is a *string*, not a digit: `"12"` and `"13"` both have two characters. **Check what the suites use as their canonical *unrecognised* marker before taking the next value**: five suites used `"10"` for that, and it had to move to `"99"` when `"10"` went live, or they would have been asserting the refusal of a marker the app opens (Q28) |+| Classify the new state | `BootstrapState` (`LibraryRepository+BootstrapState.swift`) is an ordered match the compiler checks for exhaustiveness, and the lagging generation is the **payload** of `markerLagging(generation:)` rather than a case per generation — the per-generation cases went with `data-model-cleanups` Decision 2. So a bump moves the two constants and the arm's wording and adds **no** case (`place-extraction` Q64) |+| Add the upgrade path | A marker-lagging branch that runs the data pass, validates, and publishes the new marker *after* the work it certifies — never before. **`place-extraction` is the live worked example**: `BootstrapState.markerLagging` plus the `"12"` arm in `act(on:)`, with `MarkerGenerationThirteenTests` pinning the sequence, the failure that must leave the marker put, and both halves of the extension's fork. A stage with no data pass still needs the generation, and validation is what certifies it | | Keep the extension out | The extension opens only the current marker version. It must never migrate: it holds a shared lock, and two invocations can run concurrently. This mattered most for a stage that **removes** — a concurrent share-sheet open mid-conversion is destructive rather than merely early — but the rule is unconditional |-| Extend the archive, if the schema is reader data | A new column the reader owns needs an archive generation too — `work-creators` is the freshest worked example, 10/11 → 11/12 with `BackupV11Exporter`/`BackupV11Codec` replacing the V10 set outright and three new record types (`BackupV11Creator`, `BackupV11CreatorRole`, `BackupV11Credit`) joining it; `series-and-related-works` did the same at 9/10 → 10/11 with two, `work-and-reading-status` at 8/9 → 9/10 (Q17, Q34) and `rule-citation-by-uuid` at 7/8 → 8/9 — or a backup silently stops round-tripping it. **A new *table* is a new record type, not a new column on an existing one**, and the importer needs an upsert guard and a refusal for every reference it cannot resolve; each older importer was deleted outright when its successor landed rather than kept beside it (`series-and-related-works` Q13). A stage that only *removes* store columns changes no wire shape: V9 re-recorded no golden. But a change to a record the archive *carries* does, whatever the store schema does — 8/9 was a codec change with no schema stage behind it, which is why Q9 pins the schema number to the store the archive was taken from. Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1` mode (`rule-citation-by-uuid` Q22) rather than by hand |+| Extend the archive, if the schema is reader data | A new column the reader owns needs an archive generation too — `place-extraction` is the freshest worked example, 11/12 → 12/13 with `BackupV12Exporter`/`BackupV12Codec` replacing the V11 set outright and two new record types (`BackupV12Place`, `BackupV12PlaceSuppression`) joining it, plus a test tying `schemaVersion` to the live schema so the fourth thing of a bump cannot be forgotten (Q66); `work-creators` did the same at 10/11 → 11/12 with three, `series-and-related-works` at 9/10 → 10/11 with two, `work-and-reading-status` at 8/9 → 9/10 (Q17, Q34) and `rule-citation-by-uuid` at 7/8 → 8/9 — or a backup silently stops round-tripping it. **A new *table* is a new record type, not a new column on an existing one**, and the importer needs an upsert guard and a refusal for every reference it cannot resolve; each older importer was deleted outright when its successor landed rather than kept beside it (`series-and-related-works` Q13). A stage that only *removes* store columns changes no wire shape: V9 re-recorded no golden. But a change to a record the archive *carries* does, whatever the store schema does — 8/9 was a codec change with no schema stage behind it, which is why Q9 pins the schema number to the store the archive was taken from. Re-record the golden through `BackupGoldenExportTests`' `ASTERISM_RECORD_GOLDEN=1` mode (`rule-citation-by-uuid` Q22) rather than by hand. **A table the archive reaches by a UUID column** — as `Place` is — also owes the update-path ownership rule of `place-extraction` Q76, because a `modifiedAt` guard cannot protect a field nothing timestamps |  `specs/relational-references/` is the full worked spec for a relational bump. @@ -343,7 +423,7 @@ every freeze and confirm each hit names the new live schema.  ### Recorded-store fixtures and the registry -`V11RecordedStoreFixture` seeds a store *through* the frozen snapshot, which is+`V12RecordedStoreFixture` seeds a store *through* the frozen snapshot, which is the only way to get a genuinely previous-version store without committing a binary — and it is the same registry hazard, deliberately taken. What makes it safe is **ordering, not the schema**:@@ -363,10 +443,11 @@ stored shape was a strict subset of V8's, because the stage only removed, so a live key a stale V8 registration could not answer did not exist. The note warned that "a version that *adds* loses the subset relation, and the ordering above becomes the only thing holding it up" — **V10 was that version**, and every bump-since has widened the gap: V11 added two `Work` columns and two tables, and V12-adds `Creator`, `CreatorRole` and `WorkCredit`, three whole entities the frozen-V11 snapshot cannot name. A snapshot registration left live *would* meet keys it-cannot answer. Nothing but `V11RecordedStoreFixture`'s+since has widened the gap: V11 added two `Work` columns and two tables, V12+added `Creator`, `CreatorRole` and `WorkCredit`, and V13 adds `Place` and+`PlaceSuppression` — two more whole entities the frozen V12 snapshot cannot+name. A snapshot registration left live *would* meet keys it+cannot answer. Nothing but `V12RecordedStoreFixture`'s create-seed-save-**release** ordering, plus `make test-core`'s `--no-parallel`, keeps the registry coherent. Write the next recorded-store fixture the same way and say so in its doc comment.@@ -385,16 +466,17 @@ precondition in `specs/retire-migration-chain/` Decision 6, not a formality.  ## History — lessons for the next schema bump -### The marker generation has moved eight times, and the old ones were kept until they were provably unreachable+### The marker generation has moved nine times, and the old ones were kept until they were provably unreachable  `"4"` (the relationship pass, `retire-migration-chain`) → `"5"` (`configurable-work-types`) → `"6"` (`character-extraction`) → `"7"` (`relational-references`) → `"8"` (`multi-site-works`) → `"9"` (`drop-superseded-columns`) → `"10"` (`work-and-reading-status`) → `"11"`-(`series-and-related-works`) → `"12"` (`work-creators`), which is what+(`series-and-related-works`) → `"12"` (`work-creators`) → `"13"`+(`place-extraction`), which is what `publishReadiness` writes today. Each bump superseded a statement that had read as permanent: the note said "the readiness marker holds `"5"`", then `"6"`, then-`"7"`, then `"8"`, then `"9"`, and each time the *old* value stayed in+`"7"`, then `"8"`, then `"9"`, then `"12"`, and each time the *old* value stayed in `appOpenableMarkerVersions` rather than being replaced. That is the lesson, not the digits: a device that has not launched the new build yet is on the old marker, and the set is what keeps it openable.@@ -405,10 +487,11 @@ through `"9"` was one character, and enough of this note and its readers said string, compared as a string, and any code that indexes or length-checks it is wrong. -The set has been *shrunk* five times. Four were on the same grounds rather than+The set has been *shrunk* six times. Five were on the same grounds rather than on a change of mind about the rule: `data-model-cleanups` removed `"4"`–`"6"`, `drop-superseded-columns` removed `"7"`,-`work-and-reading-status` removed `"8"`, and `work-creators` removed `"10"` —+`work-and-reading-status` removed `"8"`, `work-creators` removed `"10"` and+`place-extraction` removed `"11"` — each by establishing that the population had passed them (one user, every device confirmed on the successor). **The fourth, in order, was not — for one commit.** `series-and-related-works`@@ -416,14 +499,15 @@ removed `"9"` with the precondition unticked (Q32), and kept the V9 → V10 *sta which protects nothing, because the marker check refuses a `"9"` store before any container is constructed. The owner confirmed the population on 2026-09-06 and the follow-up retired the stage and its snapshot (Q60), so the set is back on the-rule — and `work-creators` ticked its box *before* phase 1 rather than after it-(Q15). The order still matters for the next bump: add the+rule — and both bumps since have ticked the box *before* the freeze rather than+after it (`work-creators` Q15; `place-extraction`'s `prerequisites.md`, confirmed+2026-09-10). The order still matters for the next bump: add the generation, ship it, and only retire the predecessor once every device is known to be past it.  `V6` was likewise "the live schema" and the plan was `[V5, V6]`; so were V7, V8,-V9, V10 and V11. Every one of those statements was true and every one moved on-schedule.+V9, V10, V11 and V12. Every one of those statements was true and every one moved+on schedule.  ### Nesting every entity is what makes an in-module snapshot possible @@ -443,8 +527,8 @@ snapshots are inert. zero top-level `@Model` types, and make the top-level names typealiases. The now-deleted `V4MigrationBootstrapTests` seeded genuine frozen-`AsterismSchemaV3` stores and the bootstrap migrated them in-process with no collision;-`V11RecordedStoreFixture` does the same through the-frozen V11 snapshot today. That+`V12RecordedStoreFixture` does the same through the+frozen V12 snapshot today. That in-process seeding is the second reason to keep the nesting: it is how a conversion test gets a store at the previous version without committing a binary fixture.
docs/agent-notes/testing.md Modified +94 / -20
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex 367dbca..f50b33c 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -228,7 +228,7 @@ unaffected.  The message above is the shape the crash took when the mismatched key was `Site.entries` and V3/V4 were the frozen snapshots. Those are long gone; the live-schema is now **V12** and the one frozen snapshot is `AsterismSchemaV11`.+schema is now **V13** and the one frozen snapshot is `AsterismSchemaV12`.  **The hazard has now been sharp in both directions, and V10 and V11 had it in the original one.** V9 was the first schema that *removed*, which made the live@@ -238,14 +238,15 @@ classes no longer declared, and `Site.works` aborted a whole test process (Q29 o (`workStatusRaw`, `readingStatusRaw`, `verdict`) and V11 added two optional ones plus `Series` and `WorkLink`, so the live entity was the wider one again and a stale V10 registration cost a column that would not save — the quieter failure,-and the harder one to read, because every *other* column persists. V12 adds-`Creator`, `CreatorRole` and `WorkCredit` and no column at all, so the entities-V11 and V12 share are identically shaped and the live schema is wider only by-three whole tables the snapshot never declares — a different shape of the same-hazard, not an absence of it. `V11RecordedStoreFixture` opens containers over-`AsterismSchemaV11` in the same process as every suite using the live V12 classes-(`V11RecordedStoreTests`, `V4RecordedStoreTests`, `CertificationPathTests`,-`StoreMetadataTests`, `MarkerContractTests`, `MarkerGenerationTwelveTests`), which is why its `write(at:)`+and the harder one to read, because every *other* column persists. V12 added+`Creator`, `CreatorRole` and `WorkCredit` and V13 adds `Place` and+`PlaceSuppression`, neither of them a column, so the entities V12 and V13 share+are identically shaped and the live schema is wider only by two whole tables the+snapshot never declares — a different shape of the same+hazard, not an absence of it. `V12RecordedStoreFixture` opens containers over+`AsterismSchemaV12` in the same process as every suite using the live V13 classes+(`V12RecordedStoreTests`, `V4RecordedStoreTests`, `CertificationPathTests`,+`StoreMetadataTests`, `MarkerContractTests`, `MarkerGenerationThirteenTests`), which is why its `write(at:)` releases the snapshot container before returning — see `docs/agent-notes/schema-migration.md`, where the loss of the live-within-frozen subset relation is now a lived fact rather than a prediction.@@ -275,9 +276,11 @@ of those same 32 tests took 1,380 s and breached a regression ceiling on an untouched arm; that is host contention, not a band. See `specs/bugfixes/m4-fixture-work-matching/measurements.md` §6. `series-and-related-works` then added `M4SeriesScalePerformanceTests`, ~32 s over three more measured arms, and `work-creators` added-`M4CreatorScalePerformanceTests`, so the suite count is **7** and the test count-**40** — measured as one run at **1,142 s** (19 m 2 s) plus the release build, so-the target is still ~21 minutes).+`M4CreatorScalePerformanceTests`, and `place-extraction` added one arm and no+suite, so the suite count is **7** and the test count+**41** — measured as one run at **1,070 s** (17 m 50 s) plus the release build, so+the target is still ~21 minutes; the new arm is a 3.5 ms measurement over 20+samples and costs no measurable wall time). It reported **four or five known issues** at the time this paragraph was written (nine now, see below) — Req 10.1's settling pass (`duplicate-reconciliation` Decision 27), Req 5.5's@@ -306,18 +309,28 @@ for i in 1 2 3; do make test-performance-m4 RUNS=1 > /tmp/m4-run$i.log 2>&1 || t **Nine is the steady state since `work-creators`** — four before `multi-site-works` (or five on a noisy run), nine after it, eight after `drop-superseded-columns`, seven after T-2093 retired the settling pass, eight-after `series-and-related-works`, nine again now. The nine are Req 5.4's three+after `series-and-related-works`, nine again now, and **still nine at+`place-extraction`**, which added none and retired none. The nine are Req 5.4's three capture-projection arms, Req 5.5's three diagnosis re-derivations, the full-tier no-op reconcile, `series-and-related-works` Req 14.6's link-dedupe budget (new at V11, ~9% under the cost of the phase, whose 500-row fetch is four fifths of it — that spec's Q59 and `verification-run.md` §2), and — new at V12 — `work-creators` Req 11.6's **`dedupe-credits-noop`**, a 50 ms budget measured at-0.0626–0.0651 s over ~2,000 credits, of which the whole-table fetch is 76% (that-spec's Q73 and `verification-run.md` §2.2, 130 ms ceiling).+**0.0591–0.0651 s** over ~2,000 credits, of which the whole-table fetch is 76% (that+spec's Q73 and `verification-run.md` §2.2, 130 ms ceiling; the low end is+`place-extraction`'s run).++**One of the nine is worth watching rather than changing.**+`dedupe-links-noop`'s band now runs **0.0100–0.0112 s** against a 10 ms budget,+and `place-extraction`'s run put it **0.3%** over, at 0.010032 s — the closest+it has come to fitting. (That spec's verification run says "3 µs" in prose and+"breached 1.003×" in its table; the table is right.) That known issue is *not* `isIntermittent`, so a quiet host that lands+*under* 10 ms turns it into a second way for the target to be red. Note it; do+not move the budget on one run (`specs/place-extraction/verification-run.md` §3).  **The intermittent tenth is `creator-converge-noop`**, and a loaded host will report ten rather than nine. It is *inside* its 10 ms budget on a quiet host —-0.0094–0.0100 s over four samples, never more than 6% clear of it — which is+**0.0081–0.0100 s** over five samples, never more than 19% clear of it — which is less headroom than this host moves between runs of unchanged code, so it is wrapped `withKnownIssue(isIntermittent: true)` with a 20 ms ceiling outside the block (`work-creators` Q74). Asserted plainly it would have made a busy machine a@@ -331,7 +344,20 @@ ceiling — **V11 left all eight where they were** (`specs/series-and-related-works/verification-run.md` §3), and **V12 left them there too and added its own**: the three new tables are empty in every fixture but the creator suite's, so a V12 store of the composed fixture is the work a V11-store was (`specs/work-creators/verification-run.md` §3).+store was (`specs/work-creators/verification-run.md` §3). **V13 left them there+too**, for the same reason: `Place` and `PlaceSuppression` are empty in every+fixture the target opens, so a V13 store of the composed fixture is the work a+V12 store was (`specs/place-extraction/verification-run.md` §4).++**One arm was added and one moved.** `place-ranking-200x50` measures the ranking+function over places at **0.0035 s** against a 10 ms budget and a 50 ms ceiling+— `character-ranking-200x50`'s bounds, because the ranker is one generic+implementation over `RecordRow` and the arm exists to show the second+conformance costs what the first does. The character arm itself moved from+0.0023–0.0025 s to **0.0037 s** in the same change, and that is the cost of+`CharacterRanking` becoming `RecordRanking`, not a regression: every other arm+in that run went *down* 3–10% on a quieter host. Read 0.0037 s as its new+resting place.  Two have retired, both of them Req 10.1's, and both by attribution rather than by moving a bound:@@ -555,7 +581,8 @@ exists". (Req 8.3) are about a *regular-width* window and cannot pass on the phone — there is no sidebar there to find. They share the one UI-test bundle with the phone journeys, so `make test` and `make test-ui` name them in `IPAD_ONLY_SUITES` and-skip them; `make test-ui-ipad` is where they run.+skip them; `make test-ui-ipad` is where they run — **24** cases, 22 of them+`WideLayoutUITests`' and 2 the accessibility class's.  If you add another regular-width suite, add it to that variable **and** to `test-ui-ipad`'s `-only-testing` list. A suite that cannot pass on the@@ -669,8 +696,8 @@ Two things that cost a run each, worth knowing before writing the next one:  ## A sidebar-shaped `test-ui-ipad` failure is the simulator, not the code -Six of the ten `WideLayoutUITests` failed on `iPad Pro 11-inch (M5)` on-2026-09-01 — every one of them a test about *where the sidebar is*+Six of the ten `WideLayoutUITests` there were then failed on+`iPad Pro 11-inch (M5)` on 2026-09-01 — every one of them a test about *where the sidebar is* (`…CarriesTheThreeTabsAndSettings…`, `…SidebarToggleExposesItsState`, `…ActionableBannerSitsAtTheSidebarsFoot`, `…SelectingWorksInTheSidebar…`, `…SelectingStatsFillsThePane…`, `…PortraitOpensWithoutTheSidebar…`), while the@@ -711,6 +738,53 @@ why it is worth writing down. A green `make test-ui` on this branch therefore means "these three failures and no others". Compare against that, not against zero. +**What the bundle holds, as of `place-extraction`** — counted by grepping+`func test` per class, which is the only count that does not go stale silently:+**171** cases across the UI-test bundle. `make test-ui` skips the two iPad-only+suites by name (§"Some UI suites are iPad-only"), and the last full run on+2026-09-11 reported **150** on the phone destination (the per-class grep+gives 147; compare a run against the runner's total, not the grep),+**two** of them reported *skipped* —+`M4ScaleRecentPerformanceUITests`' two `…Signpost…` measurements, which throw+`XCTSkip` off a physical device — and three of them the seed-timeout failures+above. `make test-ui-ipad` runs the other **24**.++Per suite, where this feature moved the number: `CharacterExtractionUITests`+**15** (plus `CharacterExtractionOutcomeUITests`' 2 in the same file),+`AccessibilityJourneyUITests` **14** (plus `WideLayoutAccessibilityUITests`' 2 in+the same file — the iPad case is a class of its own so the Makefile's per-*suite*+skip can reach it), `WideLayoutUITests` **22**, `WorkDetailActionsUITests` 14.+`AsterismTests` gained **no new file**: the record-session coverage the task list+planned as a `WorkDetailRecordSessionTests` landed inside the existing+`WorkDetailCharacterTests`, which is where the character drafts were already+covered.++## Two facts the review sheet's journeys are built on++Both measured while adding the place cases to `CharacterExtractionUITests`+(`place-extraction` task 24), and both look like a broken assertion rather than a+harness fact.++- **The review list scrolls, so a row assertion has to walk it.** Rows are+  `Form` sections in a lazy list: a row below the fold is not in the tree, and a+  row that has scrolled back *off* is out of it again. So an assertion about+  several rows cannot look them up one at a time — by the time it reaches the+  third, the first may be gone. `reviewListLabels(_:)` is the shape: swipe to+  the top, then walk down once collecting every identifier it still needs,+  returning as soon as it has them all. A tap needs the same treatment in the+  other direction (`tapReviewControl`, which swipes *up* first, because a+  journey that has scrolled to the foot cannot reach a control above it —+  `scrollUntilTappableAndTap` only ever swipes one way).+- **Every element of a SwiftUI `Label` carries the identifier** — the glyph+  included — so `app.anyElement(id).label` returns whichever one the query+  happens to hit, which may be the image with an empty or symbol-derived label.+  A journey asserting the *text* of a `Label` must gather all of them+  (`descendants(matching: .any).matching(identifier:)`) and look for the+  sentence across the set. The work page's proposals indicator is the live+  example. This is the same family as the container-identifier rule below, from+  the other end: there a container donates its name to its children, here one+  view publishes several elements that all wear the name it was given.+ ## The Works options menu is a clipped popover, and `swipeUp()` skips rows in it  Every row of a SwiftUI `Menu` is in the accessibility tree at its **content**
docs/asterism-design.md Modified +32 / -8
diff --git a/docs/asterism-design.md b/docs/asterism-design.mdindex fed73d5..9084960 100644--- a/docs/asterism-design.md+++ b/docs/asterism-design.md@@ -344,13 +344,14 @@ View mode is a reading surface, not a summary: the notes are the content and the 3. **Notes on this work** — generic free-form notes as a plain paragraph under the tags, where non-empty. The lede of the page; it has no header and no card of its own, because an empty field on a read screen invites an edit the screen is not offering. 4. **Series** — one row naming the series this work is in and its position within it (`series-and-related-works`), under the notes and above the reading action. It is a link to the series screen where the series resolves, and plain dim text reading "Unavailable series" where the row has not reached this device. A work in no series has no row at all — an empty field on a read screen invites an edit the screen is not offering, exactly as for the generic notes above. 5. **Open last noted chapter** — primary action, and the screen's one gradient button. Labelled precisely: it opens the newest entry's URL ("back to where I was"), not the latest published chapter.-6. **Characters** — the work's cast as pills. Tapping one expands an inline card beneath the row (not a sheet): the serif name with its aliases as chips, the character note, then each extracted fact on a gutter. A fact whose cited note still exists carries that note's chapter number in the gutter as a link that opens it; a fact from a note with no number gets an arrow and keeps its caption; a fact from the work's own notes or from a note that is gone shows a dash and says which. Full behaviour in `specs/character-extraction/`.-7. **Related works** — the undirected links this work is an end of (`series-and-related-works`): one row per link, the reader's own word for the relationship as a neutral tag pill — "adaptation", "sequel", anything — then the other work's title, which opens it. The type is free text, not a vocabulary the app owns, and the suggestions offered while typing are the spellings already in the library rather than a fixed list. A link whose other end the library does not hold reads "Unavailable work" and does not navigate; the reader can retype or remove it, because a reference in transit and a reference that will never arrive look the same from here.-8. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** orders by the URL rule's sequence where that sequence is a site-wide id — so a Royal Road interlude sits between the chapters it was posted between even with no number in its title — then the notes with a chapter number (from a title, or from a sequence that is one) by that number, then the rest; every run oldest-first on a tie (Q4, Q26). **Tapping a row opens that entry's detail screen** (Q56).+6. **Characters** — the work's cast as pills. Tapping one expands an inline card beneath the row (not a sheet): the serif name with its aliases as chips, the character note, then each extracted fact on a gutter. A fact whose cited note still exists carries that note's chapter number in the gutter as a link that opens it; a fact from a note with no number gets an arrow and keeps its caption; a fact from the work's own notes or from a note that is gone shows a dash and says which. Full behaviour in `specs/character-extraction/`; the review that fills this section, and the places beside it, are §6.3.+7. **Places** — where the story happens, in a section presented exactly as the cast is and directly after it (`specs/place-extraction/`): the same pills, the same inline card with the serif name, aliases, note and facts on a gutter, and the same citation links. A place is a location the notes name with a **proper noun**, at any scale — a world, a country, a city, a district, a building, a ship, a named room. A location referred to only by description ("the hotel", "her apartment") is not one, and the model is asked for proper nouns only. Places rank by the cast's own prominence rule computed over the work's places alone, and a work with **no** places shows no section at all. The **one designed exception** to that is the manual-pass row, which stays in the Characters section and reads "Look for characters and places": it is the way to find the places a work has none of yet, and hiding the word there would hide the way in.+8. **Related works** — the undirected links this work is an end of (`series-and-related-works`): one row per link, the reader's own word for the relationship as a neutral tag pill — "adaptation", "sequel", anything — then the other work's title, which opens it. The type is free text, not a vocabulary the app owns, and the suggestions offered while typing are the spellings already in the library rather than a fixed list. A link whose other end the library does not hold reads "Unavailable work" and does not navigate; the reader can retype or remove it, because a reference in transit and a reference that will never arrive look the same from here.+9. **Chapter notes** — the **spine**: one row per entry, no card and no clamp. A gutter carries the entry's rating dot over its chapter number, threaded by a rail that runs the length of the list; beside it the chapter title with its date, and then the whole note. Two orders, chosen by a two-segment capsule in the section header and reset to Newest on every open: **Newest** is lastSharedAt descending (§7), **Chapter** orders by the URL rule's sequence where that sequence is a site-wide id — so a Royal Road interlude sits between the chapters it was posted between even with no number in its title — then the notes with a chapter number (from a title, or from a sequence that is one) by that number, then the rest; every run oldest-first on a tie (Q4, Q26). **Tapping a row opens that entry's detail screen** (Q56).  Its toolbar carries **Export** (the one read action) and the pencil. -Edit mode is **seven sections** (`specs/work-creators/`, Decision 7): **Work** — one card holding the title (editable — manual provenance, survives re-parses), the **Work URL**, the type and the genre tags, each under its own caption; **Notes**; **Status** — one card holding both capsules and, under the reading status, the **verdict** (only while the reading status is finished or abandoned); **Credits**; **Series & related works** — the series, the work's **position** in it, and the links; **Characters**; and **Manage** — **Review URL identity**, **Re-teach URL rule**, **Merge into…**, any **Remove from {hostname}**, and **Delete work** (§9). Merge is reachable only from here. The three collections — credits, related works, characters — are each a captioned card of **compact lines**, one per record, with a bordered full-width footer button that adds another; tapping a line opens that record's **editor sheet**, which is where its own controls and its way off the work live. Adding is a bordered button or a "+" glyph inside the row it fills; removing is `secondaryText` with a minus or trash glyph, never system red (style guide §7, §11). The two statuses are three-segment capsules rather than menus, each under its own caption, because both contain a segment called "Finished" (style guide §7). The series is a menu of every series the device holds, each drawn with the same qualifier the rest of the app uses so two series called "Ashfall Cycle" are distinguishable in it, plus a **New series** action that creates the series immediately and says so — it survives cancelling the edit, because creating a series and putting this work in it are two separate things the reader did. The position is a decimal with one fraction digit, entered and shown in the reader's own locale. A related work's line opens an editor sheet where its word is retyped or the link removed, and both still commit on the spot.+Edit mode is **eight sections** (`specs/work-creators/`, Decision 7; `specs/place-extraction/` Req 3.2): **Work** — one card holding the title (editable — manual provenance, survives re-parses), the **Work URL**, the type and the genre tags, each under its own caption; **Notes**; **Status** — one card holding both capsules and, under the reading status, the **verdict** (only while the reading status is finished or abandoned); **Credits**; **Series & related works** — the series, the work's **position** in it, and the links; **Characters**; **Places**; and **Manage** — **Review URL identity**, **Re-teach URL rule**, **Merge into…**, any **Remove from {hostname}**, and **Delete work** (§9). Merge is reachable only from here. The four collections — credits, related works, characters, places — are each a captioned card of **compact lines**, one per record, with a bordered full-width footer button that adds another ("Add a place" reads like "Add a character", from one copy table); tapping a line opens that record's **editor sheet**, which is where its own controls and its way off the work live. Adding is a bordered button or a "+" glyph inside the row it fills; removing is `secondaryText` with a minus or trash glyph, never system red (style guide §7, §11). The two statuses are three-segment capsules rather than menus, each under its own caption, because both contain a segment called "Finished" (style guide §7). The series is a menu of every series the device holds, each drawn with the same qualifier the rest of the app uses so two series called "Ashfall Cycle" are distinguishable in it, plus a **New series** action that creates the series immediately and says so — it survives cancelling the edit, because creating a series and putting this work in it are two separate things the reader did. The position is a decimal with one fraction digit, entered and shown in the reader's own locale. A related work's line opens an editor sheet where its word is retyped or the link removed, and both still commit on the spot.  The **credits editor** is the Credits card: one compact "NAME: role · role" line per credit — the same line view mode draws — and "Add a creator" beneath them. Tapping a line opens that credit's sheet: every active role as a toggle chip in list order, the "New role" chip, and "Remove credit". Unlike a link, a credit is **part of the work's own transaction** — the chips edit a draft and the checkmark writes it, and cancelling discards it. The two exceptions are the things the editor *creates*: **Add a creator** opens a search over every active creator, showing one already credited on this work as unselectable with the reason rather than hiding it, and offering "New creator" where the typed name matches none; and a **New role** chip, inside that sheet, adds a role to the settings list and switches it on. Both exist in the library the moment they are confirmed and survive cancelling the edit, because making a creator and crediting them are two separate things the reader did. A role the editor cannot show — removed, merged, or not yet arrived — is written back untouched, so an edit on one device never quietly strips what another device recorded. With **no** active role at all the sheet says so and the credit stays editable: a credit records who worked on the work, and what they did is optional. @@ -371,7 +372,8 @@ The **navigation title** is the parsed chapter title — `chapterTitle`, or the 1. **Title card**: the assigned work, a `link` glyph opening the entry's rawURL, and the **rating** toggles (Q54). 2. **Note**, editable in place, with the capture's date as a quiet caption directly beneath it. 3. **Move to…** and **Delete entry** (§9).-4. **Capture details** (collapsed, last): the capture's site and the title-recovery actions (**Teach**/**Re-teach**, **Re-parse**). The error-handling surface: needed when a title came out wrong, never while writing a note (`specs/polish-and-export/`, Q49).+4. **Characters** and then **Places** — which of the work's records have facts citing *this* note, each with a count of them rather than the statements themselves (a record can cite one entry several times, and the facts are edited on the work page). Two sections, in name order, each absent rather than empty (§6.3).+5. **Capture details** (collapsed, last): the capture's site and the title-recovery actions (**Teach**/**Re-teach**, **Re-parse**). The error-handling surface: needed when a title came out wrong, never while writing a note (`specs/polish-and-export/`, Q49).  There is **no provenance block on this screen** (Q52). captureTitle, its source, canonicalURL and per-field provenance are debugging evidence; library diagnostics is where they live. @@ -380,6 +382,28 @@ Actions: - **Re-parse** (single-entry escape hatch; respects per-field manual provenance). - **Move to…** — the complete manual recovery path: pick an existing Work, **New Work…** (create-and-assign inline), or **Leave unattached** (sets intentionallyUnattached). Writes manual workAssignmentProvenance. +### 6.3 Characters and places: one pass, one review, two collections++The extraction pass that reads a work's notes for its cast also proposes the named **places** those notes mention (`specs/place-extraction/`). A place is a second record kind beside the character with the same reader-visible shape — name, aliases, a free-text note, cited facts with evidence spans — its own section on the work page (§6, item 7), and its own list on an entry. One model request per source returns both kinds; there is no second sweep, second budget or second coverage record.++**One noun table.** Every string that names a kind — the review sheet's section caption and segment labels, the work page's section header and card caption, "New place", "Delete place", "Add a place", the conversion action, the torn notice — comes from `RecordKindPresentation`. A noun spelled in two files is two screens free to disagree about it.++**The review sheet decides both kinds in one sitting.** It is titled "Suggested characters and places", each row carries its kind as a caption under the name, and the per-row Keep, Skip, fact untick and alias strike work identically for both. Three elements are new:++- **The kind switch.** A candidate row carries a two-segment Character | Place control above its Keep/Skip pair — the model files places as characters often enough that correcting it before keeping is worth one tap. The candidate's name, aliases, facts and evidence spans carry over, ticks and strikes survive, and the facts are re-keyed and re-deduped under the new kind. If the new kind already holds a record of that name the row is redrawn **at once** as an addition to it. A row never vanishes on a toggle: one whose facts all dedupe away under the new kind stays, with nothing left to keep and the Skip beside it still deciding it.+- **The dual-kind row.** Where one source returned the same name under both kinds and neither matches anything the reader has, it is shown once, as a character, with "Suggested as both a character and a place" beneath it — because skipping it decides both, and the reader must see the wider action before taking it.+- **The cross-kind hint.** Where a row's name matches a record of the *other* kind, a quiet line says so — "You already have a character with this name". It commits nothing and changes no matching; it is the reason the switch above it is worth looking at, which is why it is secondary text and not the amber the app reserves for a state the reader has to clear.++The indicator that opens the sheet counts held rows of both kinds and says "3 suggestions from your notes" — a count labelled "character suggestions" opening a list with places in it would be the screen lying about its own button. The manual trigger, likewise, reads "Look for characters and places".++**Skipping is per kind.** Skipping a place candidate suppresses that name as a place and says nothing about a character of the same name; a skipped fact suppresses that fact under its record's kind. A dual-kind row skipped once suppresses under both, because it is one thing the reader skipped once. Accepting under a kind clears that kind's standing suppressions of the name and of the facts kept, and touches the other kind's not at all.++**Conversion is the editor's third structural action.** A record already kept can be turned into the other kind from its editor sheet, where "Make this a place" sits between Combine and Delete; a staged conversion dismisses the sheet, the line moves cards immediately, and the take-back — "Make this a character again" — is reached by reopening the line from the other card. Nothing is written until the edit session's one **Save**, like every other structural change on that screen. What Save then performs is a **delete-and-recreate**: a record of the other kind with a *new* identity carrying the name, aliases, note, facts, citations, evidence spans and the retained name key, the original deleted, and the suppressions squared up on both sides. Two consequences worth stating, because neither is a bug: a conversion is not reversible by identity, so restoring an archive taken before one recreates the original beside the converted record and that pair is the reader's to clean up; and a later proposal of that name under the *original* kind arrives as an ordinary candidate, which the kind switch routes to the converted record.++**Entry detail lists both.** Where any of a work's places have facts citing the entry on screen, they get their own section in name order, directly after the characters one and separate from it — a merged list would need a kind label on every row for no gain. Either section is absent rather than empty: most notes name nobody and nowhere the reader has kept.++**A place belongs to one work**, is ranked among its work's places alone, and survives backup, restore, sync, merge and duplicate collapse exactly as a character does. There are no links between places and characters, no hierarchy between places, no map, and no place shared across works.+ ---  ## 7. Entry ordering@@ -447,7 +471,7 @@ Unattached entries export as a bare entry block. No full-library markdown export  **The exporter validates its own output**: encode → immediately decode into an in-memory representation → verify counts and relationship references. The archive carries a metadata header: backup format version, database schema version, app build, export timestamp, entry/work counts, checksum. This proves each backup file is syntactically usable and internally coherent without requiring the restore UI. -**Restore/import shipped in v1**, under Settings → Import. It was planned as a v2 item held up only by §13's release gate; M3 built it and M4b reshaped it, so the gate is already satisfied. It is a **modification-guarded upsert keyed by application UUID**, not a restore-over-wipe: import adds what the library is missing and updates only what the archive knows better, so filling an empty library is the degenerate case of the same operation rather than a separate flow. Only native `4/4` archives are accepted — the `2/2` and `3/3` paths were retired once every archive worth importing had been re-exported, and reading a pre-M3.5 archive now means checking out a build that still carries those codecs. The markdown exports stay clean and document-shaped, unpolluted by any of this.+**Restore/import shipped in v1**, under Settings → Import. It was planned as a v2 item held up only by §13's release gate; M3 built it and M4b reshaped it, so the gate is already satisfied. It is a **modification-guarded upsert keyed by application UUID**, not a restore-over-wipe: import adds what the library is missing and updates only what the archive knows better, so filling an empty library is the degenerate case of the same operation rather than a separate flow. Only archives of the **current** generation are accepted — **12/13** today (format 12 over schema 13) — and every older importer was deleted when its successor landed rather than kept beside it, so reading an older archive means checking out a build that still carries its codec. The corollary is worth stating plainly: an archive exported *before* a schema bump is unreadable by the build that made the bump, and the restorable archive is one exported after upgrading. The markdown exports stay clean and document-shaped, unpolluted by any of this.  Thumbnails, when they arrive in v2, are stored as externally-stored `Data` or a managed local file reference — not CKAsset in the application model. CKAsset is a CloudKit record representation; the persistence layer owns that mapping. @@ -467,7 +491,7 @@ Thumbnails, when they arrive in v2, are stored as externally-stored `Data` or a - Duplicate handling: identical auto-collapse, divergent review sheet. - Search: notes/chapters/work titles on Recent; work titles on Works. - Export: per-work and per-entry markdown.-- Full-library JSON backup export with self-validation, and backup import (Settings → Import): a UUID-keyed upsert accepting native `4/4` archives (§10).+- Full-library JSON backup export with self-validation, and backup import (Settings → Import): a UUID-keyed upsert accepting archives of the current generation only (§10). - Schema versioning from the first build; dev/personal store split (§13).  ### v2+@@ -542,7 +566,7 @@ The defined transition path is the backup (§10): 3. Import the archive into the production store. 4. Verify CloudKit sync; retain the archive until records appear on a second device. -Consequence: the **backup importer must exist and be tested before any TestFlight or public build** — it is the bridge, not a nice-to-have. **That gate is met**: the importer shipped in v1 (§10), is exercised by `BackupV4ImportMatrixTests` and `BackupImportTransactionTests`, and steps 1 and 3 have been rehearsed repeatedly against real libraries — exporting from `Personal` and importing into `Development` is routine and works. What is untested is only what needs a production-backed build to test: step 2, and step 4's verification that imported records reach CloudKit's *production* environment and land on a second device. One caveat bites exactly here: import accepts native `4/4` only, so the archive fed to the production build must be exported by a build of the same era, not recovered from an older backup.+Consequence: the **backup importer must exist and be tested before any TestFlight or public build** — it is the bridge, not a nice-to-have. **That gate is met**: the importer shipped in v1 (§10), is exercised by `BackupV4ImportMatrixTests` and `BackupImportTransactionTests`, and steps 1 and 3 have been rehearsed repeatedly against real libraries — exporting from `Personal` and importing into `Development` is routine and works. What is untested is only what needs a production-backed build to test: step 2, and step 4's verification that imported records reach CloudKit's *production* environment and land on a second device. One caveat bites exactly here: import accepts the current generation only, so the archive fed to the production build must be exported by a build of the same era, not recovered from an older backup.  ### 13.3 Deferred to a v2 decision 
docs/asterism-style-guide.md Modified +12 / -5
diff --git a/docs/asterism-style-guide.md b/docs/asterism-style-guide.mdindex 42c1a74..fa19374 100644--- a/docs/asterism-style-guide.md+++ b/docs/asterism-style-guide.md@@ -109,18 +109,25 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field   - **Hue**: every creators section header takes `accent: .violet` — "Works" on the creator screen, "Creator roles" and "Removed" in Settings. The work detail has none: neither mode gives the credits a section of their own. The count pill trailing a creators-list row is §7's cyan-on-cyan-.12 one, unchanged.   - **A role is a genre-tag pill**, like a link type and for the same reason: it is free text the reader typed. In the credits editor each active role is a **toggle** drawn as that pill, `selectedGenreTag` when it is on, and a **removed** role is not drawn at all. An **unresolved** role the credit still holds is drawn `dimmedTypeTag` — the removed-type knock-down — and can only ever be switched *off*, because there is no name to switch back on.   - **An unresolved creator is the horizontal ellipsis, not the words.** A credit row whose creator has not arrived draws "…" in `secondaryText` — the work editor's unresolved-type treatment — with no chevron and the row disabled, and speaks "Unavailable creator" in its accessibility label. That is the one place the creators surfaces differ from the series ones, which spell "Unavailable series" on the row: a credit row's primary line is a *name*, and dim text where a name goes reads as a name the reader somehow gave (Q46).-  - **A view-mode credit is one compact tappable line, `NAME: role · role`, and all of them sit inside the header** — which draws no card at all — in the header's own `VStack`, under the site row and above the type and genre pills, with no section and no header of their own (`specs/work-creators/`, Q96): a `VStack` at 6 pt spacing, grouped under the identifier `work-detail-credits` as the tags row is, each line the name in `.subheadline` then a colon and the roles in `.subheadline` `secondaryText`, concatenated into a single `Text` so the line wraps as one piece; a credit with no roles carries no colon. **These lines are this guide's one deliberate exception to the ≥ 44 pt hit target**: a list row per credit paid 44 pt and the row insets for every one of them, which made two credits taller than the header that then sat above them, and a credit is a small fact with a secondary tap on it rather than a primary control. The line is still a full-width target through `.contentShape(Rectangle())`. **The work editor's three collection cards borrow it, and nothing else may** (`specs/work-creators/`, Decision 7): credits, related works and characters each draw one of these lines per record for the same reason and at the same cost, and each line opens the record's own editor sheet rather than putting its controls on the page. **Its label is the creator's name followed by its shown roles** — "Mori Ayane, author, artist" — with no "Credits" prefix, because the roles say what the line is and no header names the section any more; the roles are drawn beside the name and a reader who cannot see them would otherwise lose them. **The editor draws the same lines**: edit mode's Credits is a `constellationCaptionedCard("Credits")` holding one of these lines per draft credit — the roles read from the draft, so a chip switched on shows on the line — over a bordered full-width "Add a creator" footer button, and tapping a line opens that credit's editor sheet (`specs/work-creators/`, Decision 7). The chip group's container label, "Roles for {creator}", travels into the sheet with it.+  - **A view-mode credit is one compact tappable line, `NAME: role · role`, and all of them sit inside the header** — which draws no card at all — in the header's own `VStack`, under the site row and above the type and genre pills, with no section and no header of their own (`specs/work-creators/`, Q96): a `VStack` at 6 pt spacing, grouped under the identifier `work-detail-credits` as the tags row is, each line the name in `.subheadline` then a colon and the roles in `.subheadline` `secondaryText`, concatenated into a single `Text` so the line wraps as one piece; a credit with no roles carries no colon. **These lines are this guide's one deliberate exception to the ≥ 44 pt hit target**: a list row per credit paid 44 pt and the row insets for every one of them, which made two credits taller than the header that then sat above them, and a credit is a small fact with a secondary tap on it rather than a primary control. The line is still a full-width target through `.contentShape(Rectangle())`. **The work editor's four collection cards borrow it, and nothing else may** (`specs/work-creators/`, Decision 7; `specs/place-extraction/` Req 3.2): credits, related works, characters and places each draw one of these lines per record for the same reason and at the same cost, and each line opens the record's own editor sheet rather than putting its controls on the page. **Its label is the creator's name followed by its shown roles** — "Mori Ayane, author, artist" — with no "Credits" prefix, because the roles say what the line is and no header names the section any more; the roles are drawn beside the name and a reader who cannot see them would otherwise lose them. **The editor draws the same lines**: edit mode's Credits is a `constellationCaptionedCard("Credits")` holding one of these lines per draft credit — the roles read from the draft, so a chip switched on shows on the line — over a bordered full-width "Add a creator" footer button, and tapping a line opens that credit's editor sheet (`specs/work-creators/`, Decision 7). The chip group's container label, "Roles for {creator}", travels into the sheet with it.   - **`EditButton` is this guide's one system-worded bar control, and it is the roles list's alone.** §7's "bar buttons are glyphs, not words" holds everywhere else; a reorderable `List` has no glyph equivalent, and re-implementing edit mode to avoid the word would be a worse trade than the exception. It rides `PlatformModifiers.listEditToolbarButton(identifier:)` rather than an `#if` in the view, and it is **absent on the Mac** — `EditButton` does not exist there, and a Mac `List` row carrying `.onMove` is dragged with no mode to enter first (Q81).   - **Refusals** follow the series screens' convention exactly, including on the role detail one push below Settings: a rejected name is said **under the name field**, in `.footnote` amber inside the field's own row, with `constellationAttentionField` on the field while it stands; a refusal with no field is the bottom message row (Q89).-- **The work editor's collection vocabulary** (`specs/work-creators/`, Decision 7), shared by credits, related works, characters and Manage and defined once in `Asterism/Support/ConstellationEditorRecipes.swift` rather than once per section:-  - **A collection is a captioned card.** `constellationCaptionedCard` names it — "Credits", "Series & related works", "Characters", "Status" — and it takes **no** `ConstellationSectionHeader`: a header over a card that already names itself is two labels for one thing. Edit mode keeps three headers (Work, Notes, Manage) and no more. Inside a card that holds several controls, each takes `constellationCaptionedField` — the captioned card's caption without its card.+- **Places add no new recipe either** (`specs/place-extraction/`), for the reason series and creators added none: a place is a second *kind* of record, not a second visual language. Everything a place draws is the character surface with a different noun in it, and the nouns come from one table, `RecordKindPresentation` — the review sheet's section caption and segment labels, the section header and card caption ("Places"), the editor's "New place" / "Delete place" / "Add a place" and the conversion action all read that table, because a noun spelled twice is a chance for two screens to disagree in a way only a reader notices.+  - **Hue and composition**: the work page's "Places" section header takes `accent: .violet` like the cast's and sits directly after it, with the same pills, detail card, fact gutter and citation links. The manual-pass trigger stays in the **Characters** section and is relabelled "Look for characters and places" — one pass produces both kinds, so a second trigger under Places would be a second button for one action.+  - **The review sheet's kind control is `ConstellationSegmentedControl`**, not a `.segmented` `Picker` (`place-extraction` Q79): §7 defines the segmented capsule once and forbids a copy per screen, and the shared control is also the one that stacks its segments full width at the accessibility sizes instead of hyphenating a label. Two segments, "Character" and "Place", above the row's Keep/Skip pair, with the container label "Record kind".+  - **The cross-kind hint is `secondary` text, not amber.** "You already have a character with this name" sits under the bundle note in `.caption`, beside the two other resting notes that say what a row is. §2 reserves amber for a state the reader has to clear, and this clears nothing — it commits nothing and changes no matching; it is the reason the kind control beside it is worth looking at. The dual-kind line, "Suggested as both a character and a place", is the same recipe for the same reason.+  - **Conversion is a third structural action in the record's editor sheet**, `ConstellationFooterButton("Make this a place", systemImage: "arrow.left.arrow.right")`, between Combine and Delete — §7's bordered footer button in `secondaryText`, and **not** red: it ends nothing. Staging one **dismisses the sheet** and the line moves cards at once, because a sheet is parameterised by kind at construction and one left open would say "Delete character" over a draft that is now a place (Q81). The take-back is one tap further away, by reopening the line from the other card, where the same button reads "Make this a character again".+  - **The proposals indicator drops the noun**: "3 suggestions from your notes", counting held rows of both kinds. A count labelled "character suggestions" that opened a list with places in it would be the screen lying about its own button.+  - **One designed exception to Req 4.1.** A work with no places shows no place-related element — no empty section, no header — *except* the shared proposals indicator and that relabelled manual-pass row, which names places on a work that has none. It is the trigger for the pass that would find them, and it lives in the other section; hiding the word there would hide the way in.+- **The work editor's collection vocabulary** (`specs/work-creators/`, Decision 7), shared by credits, related works, characters, places and Manage and defined once in `Asterism/Support/ConstellationEditorRecipes.swift` rather than once per section:+  - **A collection is a captioned card.** `constellationCaptionedCard` names it — "Credits", "Series & related works", "Characters", "Places", "Status" — and it takes **no** `ConstellationSectionHeader`: a header over a card that already names itself is two labels for one thing. Edit mode is **eight sections**, of which three carry a header (Work, Notes, Manage) and five are captioned cards naming themselves; **four of those five are collections** (credits, related works, characters, places) and the fifth is Status. Inside a card that holds several controls, each takes `constellationCaptionedField` — the captioned card's caption without its card.   - **A record is a `ConstellationLineRow`**: "NAME: detail" concatenated into one `Text` with a trailing chevron, the name in `.subheadline` (the ellipsis in `.caption` `secondaryText` where it is unresolved), the detail in `.subheadline` `secondaryText`. It is the view-mode credit line, lifted.   - **Adding is a bordered footer button**, never a bare one: `ConstellationFooterButton` — full width, 44 pt, a 1 pt `cardBorder` hairline at `fieldRadius`, `.caption` semibold violet, with a `plus` glyph where it makes a record and none where it opens something. It is what replaced the four bare `Button`s that drew as opaque white system rows between two glass cards. Where the add fills a field that is already on the page — "New series" beside the series picker, "Add" beside the alias field — it is a 44 × 44 "+" glyph inside that row instead — behind a 1 pt `cardBorder` divider where it sits beside the series picker, and bare beside the alias field, which has no control to be separated from.   - **Removing is not red.** `ConstellationDestructiveRow` inside a sheet, and the footer button in `secondaryText` on the page: a `minus.circle` glyph for taking a record off something, `trash` for ending it. §11 gives the palette three hues and none of them is an error hue, and the confirmation behind Delete work is what actually guards it. A sentence beside the control says what survives it — "The creator stays in your library."   - **A record's own controls live in an editor sheet**, `ConstellationEditorSheet`: `CreatorPickerView`/`LinkTypeEntryView`'s chrome — a `NavigationStack` around a `List` with the scroll background hidden and an inline title — with the record's name as the title and **Done** alone. No Cancel: everything inside one is already written, into the work's draft or onto the record itself, so a Cancel would promise an undo the sheet cannot perform. Any alert or dialog the sheet raises belongs **to the sheet**, not to the screen behind it, which cannot present over it. - **Selected pill**: a pill that has been opened — work detail's cast pill, with its chevron pointing up — takes the type-tag recipe lifted: violet .26 fill, violet .6 border, no glow (`ConstellationPillKind.selectedTypeTag`, Q11/Q14). The neutral family has the same distinction: a genre-tag pill that is currently in force takes `selectedCardFill` inside `selectedCardBorder` with its label in primary text (`ConstellationPillKind.selectedGenreTag`), which is the selected-row recipe of §12 at pill size — selection keeps one spelling, and violet is not borrowed for it, because violet means work *type*. Neither variant glows (§5). Selection changes no other pill. - **Spine row** (work detail's chapter notes): no card. A gutter (≥ 44 pt, growing with its label) holds a 10 pt rating dot — cyan filled for ▲, violet for ▼, a 1.5 pt dim ring when unrated — over the chapter number in SF Mono dim (nothing beneath the dot where the note has no number — an interlude, or a site whose URL carries only an id, Q25), and a 1 pt `cardBorder` rail runs through the dots from the first row's to the last row's. Beside it: the row title (13 pt semibold, one line, truncating) with the date trailing in `.caption2` dim, then the whole note at 15 pt with no line limit. The rail is drawn in the row container's background, outside the row's button, so pressing a row does not dim it.-- **Segmented capsule (two or three short labels), or a menu**: use a **capsule** when a choice has two or three short labels and all of them must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1) and Stats' `Week | Month | All time` unit (`specs/stats-period-navigation/`, Q11). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. Placement follows what the control governs: **beside its section header** where it has one, dropping to its own line beneath that header where the two do not fit — work detail's sort (Q21) — and **centred above what it governs** where it stands alone, with no header to sit beside, which is Stats' unit toggle and its chevron row (`specs/stats-period-navigation/`, Q33). Either way, at the largest accessibility sizes it stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). **One implementation, not a copy per screen**: `ConstellationSegmentedControl` in `ConstellationKit` — values, a selection binding, a title and an accessibility identifier per value, and a required container label naming what the segments choose (`stats-period-navigation`, Q28). Use a **`Menu`** instead when the choice has more than three options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods before this control replaced them (`specs/stats-page/`, Q31). **Give the capsule a visible caption where the segment names alone are ambiguous**: work detail's edit mode stacks two of these controls in one `constellationCaptionedCard("Status")`, and both contain a segment called "Finished", so each sits under a `.caption` semibold `secondaryText` caption — "Work status", "Reading status" — inside that card, with the same text as the control's `containerLabel` (`specs/work-and-reading-status/`, Q26; `specs/work-creators/`, Decision 7). The verdict field below them takes the same caption recipe for its prompt ("How was it?" / "Why did you stop?") rather than as the placeholder, because a placeholder vanishes the moment the reader types and the prompt is the only thing that tells the two verdicts apart (Q16).+- **Segmented capsule (two or three short labels), or a menu**: use a **capsule** when a choice has two or three short labels and all of them must stay visible, so the reader sees the state instead of opening something to read it — work detail's `Newest | Chapter` sort (`specs/work-detail-reading-redesign/`, Decision 1) and Stats' `Week | Month | All time` unit (`specs/stats-period-navigation/`, Q11). Recipe: `cardFill` fill with a 1 pt `cardBorder`; selected segment cyan .14 fill, cyan .4 border, cyan text; unselected dim; `.caption` semibold; a 32 pt visual (scaled with Dynamic Type via `@ScaledMetric`, so the segment grows with its label) inside a 44 pt target. Placement follows what the control governs: **beside its section header** where it has one, dropping to its own line beneath that header where the two do not fit — work detail's sort (Q21) — and **centred above what it governs** where it stands alone, with no header to sit beside, which is Stats' unit toggle and its chevron row (`specs/stats-period-navigation/`, Q33). Either way, at the largest accessibility sizes it stacks its segments full width in a card-radius rectangle rather than hyphenate a label (Q21). **One implementation, not a copy per screen**: `ConstellationSegmentedControl` in `ConstellationKit` — values, a selection binding, a title and an accessibility identifier per value, and a required container label naming what the segments choose (`stats-period-navigation`, Q28). The review sheet's per-row Character/Place control is another user of it (`place-extraction` Q79), and the rule that made it one is exactly this bullet: a `.segmented` `Picker` there would have been a second spelling of the same shape, and one that hyphenates instead of stacking. Use a **`Menu`** instead when the choice has more than three options or long ones, where no segmented shape fits at the accessibility sizes — Stats' five periods before this control replaced them (`specs/stats-page/`, Q31). **Give the capsule a visible caption where the segment names alone are ambiguous**: work detail's edit mode stacks two of these controls in one `constellationCaptionedCard("Status")`, and both contain a segment called "Finished", so each sits under a `.caption` semibold `secondaryText` caption — "Work status", "Reading status" — inside that card, with the same text as the control's `containerLabel` (`specs/work-and-reading-status/`, Q26; `specs/work-creators/`, Decision 7). The verdict field below them takes the same caption recipe for its prompt ("How was it?" / "Why did you stop?") rather than as the placeholder, because a placeholder vanishes the moment the reader types and the prompt is the only thing that tells the two verdicts apart (Q16). - **Provenance disclosure**: quieter-than-card fill (.035), SF Mono 11 pt, dim, labels slightly brighter. Collapsed by default. *(No longer used on entry detail — the block was removed from that screen entirely, `specs/polish-and-export/` Q52. The recipe stands for any diagnostics surface that needs it.)*  ## 8. Iconography@@ -152,7 +159,7 @@ Concentric radius system, outside-in: sheet 44 → banner/card 20–22 → field - Both appearances ship; all tokens above are paired. The structure never changes between modes — only the token set. - Amber-on-dark text uses the lifted variant (`0.85 0.11 85`) for contrast; on light, amber text is the dark variant (`0.42 0.10 78`) — raw accent fills fail contrast as text on light. - Respect Reduce Transparency (fall back to opaque `#12162a` dark / `#f2f3f8` light card fills) and Reduce Motion (nothing extra to do — motion is already minimal).-- Hit targets ≥ 44 pt even where visuals are smaller (Teach pill, tags, chips). The deliberate exceptions are the four compact line kinds, argued above: the work detail's view-mode credit line and the editor's credit, related-work and character lines — one `ConstellationLineRow` recipe, drawn in four places.+- Hit targets ≥ 44 pt even where visuals are smaller (Teach pill, tags, chips). The deliberate exceptions are the five compact line kinds, argued above: the work detail's view-mode credit line and the editor's credit, related-work, character and place lines — one `ConstellationLineRow` recipe, drawn in five places. - Dynamic Type: serif titles scale with the system; truncate single-line work/chapter names with ellipsis rather than wrapping in rows. **Rows only** — a detail screen's own heading wraps: work detail's header title is multi-line with no line limit, and the navigation bar carries the collapsed, truncating form of it (Q58). - Where a row cannot hold everything on one line at the accessibility sizes, the trailing element moves to its own line rather than clip, hyphenate, or squeeze its neighbour: work detail's meta line drops under the site identity (`specs/work-detail-reading-redesign/`, Q18), and its sort capsule drops under the section header and then stacks its two segments (Q21). The drop-under-the-header half is specific to a capsule that *has* a header: Stats' period control has none and is centred above the graph it governs, where the same size increase stacks its segments and drops the chevron row beneath the toggle (`specs/stats-period-navigation/`, Q33). 
specs/OVERVIEW.md Modified +4 / -2
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex a32e851..4c475cf 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -41,7 +41,7 @@ | [Work and Reading Status](#work-and-reading-status) | 2026-09-04 | Done — all 20 tasks implemented 2026-09-05 across eight phases and seven design-review rounds (Q42–Q68); verification recorded in `verification-run.md`. Owner-side steps remain, all in `prerequisites.md`: an 8/9 archive from every device is the only rollback from the first V10 install, both devices must be updated before either reopens the library, a `Development` run publishes the three new fields to the dev CloudKit container, and the device checks Q58 (double-dimmed type tag), Q67 (the abandoned knock-down, which XCUITest cannot read) and Q65 (the finished-reading dialog) are eyes-only | Full spec (T-2306). Two reader-entered statuses on every work — the work's own (ongoing, finished, hiatus) and the reader's (reading, finished, abandoned) — plus a verdict text once the reader is done. Three defaulted columns under schema V10 with the V8 stage retired, markers `"9"` → `"10"`, and the archive at 9/10; the fields ride the `genreTags` authored-content chain through duplicates and merge. Finished reading requires a finished work, enforced on the picker and again at commit (Decision 1). Abandoned works dim and sort last in the Works list; both statuses are filters with dimension-qualified pills. | | [Series and Related Works](#series-and-related-works) | 2026-09-05 | Done | Full spec (T-2308, absorbs T-2309). A `Series` table with name and notes, two columns on `Work` for its series and decimal position, and a `WorkLink` table of undirected, free-text-typed links between works, under schema V11 with markers `"10"` → `"11"` and the archive at 10/11. Series list and series screen under the Works tab, a series row and related-works section on the work detail, a series filter and a stored group-by-series toggle in the works list; the compact Works stack becomes a typed route path to carry the screens. Shipped with `AsterismSchemaV9` retained (Q32), retired in a follow-up once every device was confirmed on marker `10` (Q60), and Req 14.6's link-dedupe budget as an accepted breach (Q59). | | [Work Creators](#work-creators) | 2026-09-07 | Done | Full spec (T-2316). Creators and reader-defined ordered roles as directory tables on the work-types shape, and credits as join rows on the link shape, under schema V12 with markers `"11"` → `"12"` and the archive at 11/12. Creators list and creator screen under the Works tab, a credits editor in the work's draft, a creator filter, and a creator-roles section in Settings. |-| [Place Extraction](#place-extraction) | 2026-09-09 | Planned | Full spec (T-2276). Places as a second record kind beside characters: the same model request returns both, one review list decides both with a per-row kind switch, a kept record converts between kinds in the editor, and places rank, cite entries and sync as characters do. `Place` and `PlaceSuppression` under schema V13 with markers `"12"` → `"13"` and the archive at 12/13; the character store code becomes generic over a record row. |+| [Place Extraction](#place-extraction) | 2026-09-09 | In Progress | Full spec (T-2276). Places as a second record kind beside characters: the same model request returns both, one review list decides both with a per-row kind switch, a kept record converts between kinds in the editor, and places rank, cite entries and sync as characters do. `Place` and `PlaceSuppression` under schema V13 with markers `"12"` → `"13"` and the archive at 12/13; the character store code becomes generic over a record row. |  --- @@ -694,7 +694,7 @@ Full spec (T-2316). A **creator** is a named record with notes; a **role** is a  ## Place Extraction -**Created:** 2026-09-09 · **Status:** Planned — requirements, design and tasks approved 2026-09-10 after two requirements review rounds, one design round plus a validator pass, and an owner cleanup of the prerequisites; 27 tasks in six phases across three streams, Q1–Q62 and Decisions 1–3 in the log. The owner confirmed every device on marker `"12"` on 2026-09-10, so the freeze task retires V11 in one commit.+**Created:** 2026-09-09 · **Status:** In Progress — phase 1 (generic store) landed 2026-09-10; requirements, design and tasks approved 2026-09-10 after two requirements review rounds, one design round plus a validator pass, and an owner cleanup of the prerequisites; 27 tasks in six phases across three streams, Q1–Q62 and Decisions 1–3 in the log. The owner confirmed every device on marker `"12"` on 2026-09-10, so the freeze task retires V11 in one commit.  Full spec (T-2276). A **place** is a named location in a story, kept as characters are: name, aliases, note, cited facts with verbatim evidence spans, a retained name key, durable synced suppression, combine, and hand-creation in the work page's edit mode. The character extraction request returns both kinds from one source (Q4); the review list shows both with a per-row kind switch because the model files many places as characters (Q11), a name returned under both kinds is one row defaulting to character (Q12, Q20), and a kept record converts between kinds in its editor as a delete-and-recreate staged on Save (Decision 1). Suppression, fact identity and matching are per kind (Q13) with place suppressions in their own record type so pre-feature builds cannot clear them (Decision 2). Below the pipeline the character store code becomes generic over a `RecordRow` protocol adopted by `Character` and `Place`, proved by a standalone spike (Decision 3, Q52); the pipeline, ledger and review sheet stay single implementations carrying a `RecordKind`. Schema V13 freezes V12 and retires V11; markers `"12"` → `"13"`; archive 12/13 replaces 11/12 (Q51). Detection was prototyped over a frozen corpus of 125 sources: character regression 4.4% against a 10% bar with no accepted loss, refusals one source over and accepted as variance (Q62), and the combined request 25% slower on the host, with the phone gate in `prerequisites.md`. @@ -703,4 +703,6 @@ Full spec (T-2276). A **place** is a named location in a story, kept as characte - [tasks.md](place-extraction/tasks.md) - [decision_log.md](place-extraction/decision_log.md) - [prerequisites.md](place-extraction/prerequisites.md)+- [verification-run.md](place-extraction/verification-run.md)+- [implementation.md](place-extraction/implementation.md) - [prototype/prototype-findings.md](place-extraction/prototype/prototype-findings.md)
specs/place-extraction/decision_log.md Modified +24 / -0
diff --git a/specs/place-extraction/decision_log.md b/specs/place-extraction/decision_log.mdindex 1910a73..a3ff503 100644--- a/specs/place-extraction/decision_log.md+++ b/specs/place-extraction/decision_log.md@@ -66,6 +66,30 @@ | Q60 | 2026-09-10 | `RecordRow` and `SuppressionRow` gain an archive-record associated type, `make(imported:)` and `attach(to:archivedWorkID:)` | The generic importer needs a factory that takes no work and an ownership step the two tables do differently; the character side must leave an existing relationship alone when the owner is unresolved, the place side must keep the dangling id | | Q61 | 2026-09-10 | Req 6.1 amended: measured over one work's sources in the dev library, pre-feature build then this one, not over the prototype corpus | The corpus archive is generation 7 and no current build imports it (Q57); the phone comparison needs a library both builds can open | | Q62 | 2026-09-10 | Final-text run: refusals 7 combined vs 6 character-only, one over Req 1.8's bar; every other gate passes (R 4.4%, no accepted loss) | Guardrail refusals are a classifier over instructions plus note, and one sentence of instructions moved two sources one way and one the other; the run before Q43 measured 5 vs 6 on the same corpus. Owner accepted it as one-source variance at design approval; Req 1.8's bar is amended to "not exceed by more than one source" and the shipped text is the run's |+| Q63 | 2026-09-10 | `facts` moves off `Character` onto a `RecordRow` extension and becomes a protocol requirement; `CharacterRanking.swift` and `WorkCharacterPresentation.swift` are renamed with their types | The decode is identical for every record table and Q75 requires one canonical encoding, so a per-model copy would be the drift Decision 3 exists to avoid; making it a requirement keeps a conformance from shadowing it statically. The design's four-file rename list omitted the two files whose contents also became generic |+| Q64 | 2026-09-10 | No new `BootstrapState` case at V13; `markerLagging(generation:)` carries `"12"` as its payload | The per-generation cases `markerLaggingV4/V5/V6` were retired by `data-model-cleanups` Decision 2 and the payload replaced them; the design's §Data model line and task 4's detail were written against the pre-Decision-2 shape. A bump now moves the two constants and the arm's wording only |+| Q65 | 2026-09-10 | The V12→V13 rename of every test `Schema(versionedSchema:)` and plan reference lands in the freeze task (task 4), not task 5 | Deleting `AsterismSchemaV11` and its fixture leaves those suites unresolvable, and the test target must compile at every commit, the same one-commit rule `schema-migration.md` states for the fixture itself (Q43, Q60). Task 5 keeps the marker literals, the M5 seeds and the baseline |+| Q66 | 2026-09-10 | `Place.ArchiveRecord` is `BackupV11Character` and `PlaceSuppression.ArchiveRecord` is `BackupV11Suppression` until task 15 | The protocol requires an archive record from the freeze onward, and the 11/12 payload has no places array, so neither `make(imported:)` is reachable before the codec is renamed; the two shapes differ only in `workID`'s optionality. Task 15 replaces both, and task 14 pins the archive's schema version to the live schema so the fourth thing of a bump cannot be forgotten again |+| Q67 | 2026-09-10 | `Place.make(…, work: nil)` leaves `workID` at a freshly minted UUID | Q44's "keeps the id it will re-attach by" describes the sync-race orphan, not this path; a place created with no work has no id to keep, and the fresh UUID is the column's own default, so the row is the tolerated non-resolving orphan of Req 5.5. The make-insert-attach ordering is the call sites' shape (create, convert, acceptance, suppression all pass `work: nil` to `make` and attach on the next line), and every one guards a non-empty work first, so the fresh UUID is transient |+| Q68 | 2026-09-10 | Req 1.5's survival predicate keeps `character-extraction`'s existing exception: an unmatched copy whose every reported fact deduped away, and which the model did not report name-only, is not shown | Req 1.5 and Q36 restate the predicate as "unmatched and unsuppressed survives", which is narrower than what the shipped assembler does and always did; repealing the exception would change character output on a path Req 1.8 does not measure, and a row with nothing to decide is `character-extraction` Req 1.7's rule. Consequence: such a copy cannot join a dual-kind union, so the surviving copy is single-kind and a skip of it suppresses one kind only. Pinned by a test in task 18 |+| Q69 | 2026-09-10 | The ledger's `merged` keeps the reader's `displayedKind` and the older row's previewed target; the review model re-runs the reclassify preview over the merged content | The ledger is a pure value type with no presentations and no context, so it cannot preview; the guarantee it owns is that a merged row never inherits the newer row's target, which was resolved under the assembled kind. The re-run the design describes lands in tasks 20 and 21 |+| Q70 | 2026-09-10 | The dual-kind fold carries the place copy's facts only, not its proposed aliases | Req 1.5 says "the union of their facts"; both copies come from the same slash split in practice, so an alias the folded copy proposed is one the survivor already carries. A reclassify (Req 2.2) re-derives aliases from the row it converts, not from the folded copy |+| Q71 | 2026-09-10 | `CharacterExtractionSource` keeps its name where the design's rename table says `ExtractionSource` | `AsterismIntelligence` already vends a public `ExtractionSource`, the model request shape, and the app imports both modules unqualified, so the two collide at every use site. A source is kind-independent, so the retained prefix names the pipeline that owns it rather than a record kind |+| Q72 | 2026-09-10 | `CharacterExtractionContext`'s character-only initialiser moves to a test-side extension rather than being deleted | Task 9 required its removal so no production caller could hand the assembler an empty place half; an extension inside `CharacterExtractionAssemblerTests` achieves that while keeping the fixtures readable. A test saying "this pass has no places" is a statement about a fixture, not about the library |+| Q73 | 2026-09-10 | `.convert` ignores `draft.kind`; `to:` is the authority, and only `to == basis.kind` refuses `.kindMismatch` | The draft describes content, not routing, and a draft arriving under either kind describes the same record; refusing a correct conversion over a cosmetic field would guess which of the two the reader meant. `.update` still requires `draft.kind == basis.kind`, because there the draft is the only statement of the table being written. Task 23 still flips the draft's kind on stage, for the cards. Conversion does not carry the old kind's active fact suppressions into the new table, where a combine re-keys them: suppression is per kind (Q13) and neither Req 3.7 nor the design asks for the move, so an unticked fact can be re-proposed under the new kind |+| Q74 | 2026-09-10 | `M4DuplicateScalePerformanceTests`' reconcile fixture is left unchanged; no places are seeded into it | The fixture seeds zero character rows, so "places at the character density" is zero; seeding places alone would move a 21-minute target's recorded band to measure a cost the character side never paid there. Risk 1's performance verification is `placeRankingAtScale` alone and task 25's reconcile-arm bullet drops. Collapse-path repointing over both kinds stays covered for correctness by `PlaceDuplicateMachineryTests` |+| Q75 | 2026-09-10 | Q65's "compiles at every commit" means every green commit; a red half may name the types its green half introduces | The red/green split is one task, and a red commit that compiles could not name the types under test. Every red commit on this branch says so in its message, and the branch squash-merges |+| Q76 | 2026-09-10 | On the import update path an archived place's `workID` is adopted only when it resolves in this library or the local row is itself an orphan; the insert path takes it as-is | Ownership is not timestamped (a merge moves `workID` without touching `modifiedAt`), so the `modifiedAt` guard cannot protect it, and the codec deliberately admits an archived place naming a work the payload lacks. Without the rule, re-importing an archive taken before a work merge moves a place the merge had placed onto an absent work and it becomes invisible everywhere. The character side cannot reach this because `attach` leaves an existing relationship alone; the place rule is the same intent stated over a column |+| Q77 | 2026-09-10 | The coordinator's cross-kind discard sweeps on the kinds the commit wrote a name-key suppression under, exposed once on `DecisionRequest` as `nameKeySuppressedKinds` and consumed by both the repository's other-kind write and the coordinator, not on `returnedKinds` | Over two kinds `returnedKinds.count > 1` and `suppressedKinds.count > 1` are the same predicate, so swapping fields protects nothing by itself; the protection is the gate `action == .skip && displayedTargetID == nil`, the repository's own guard. Req 2.4 forbids an accept from touching the other kind, and an unguarded sweep on an accepted union row would discard a legitimate same-name row of the other kind from another source. One gated accessor means tasks 20 and 21 cannot pass the natural but wrong field |+| Q78 | 2026-09-11 | Reclassify carries ticks by `RecordFact.displayRowID` (source token, quote, statement), not by the proposal-local index Q59 named | An index is stable only when the destination drops no facts; a projection that dedupes some away shifts the survivors and lands the reader's unticks on the wrong facts. `displayRowID` already excludes the name key for exactly this reason and is the one spelling the work page and the sheet both key reader state on. Q59's other half, the request built from the projection and never from the held row, stands |+| Q79 | 2026-09-11 | The review sheet's kind control is `ConstellationSegmentedControl`, not the `.segmented` `Picker` the design and task 21 name | Style guide §7 defines the segmented capsule once and forbids a copy per screen; the shared control is also what stacks full width at the accessibility sizes instead of hyphenating a label |+| Q80 | 2026-09-11 | Accept stays enabled on a reclassified candidate the model reported with no facts; it is disabled only when a reclassified row's facts all deduped away under the new kind | Req 1.2 admits a place candidate with zero facts and it is the common shape for a place named in passing. Disabling Keep there leaves Skip as the only action, which writes a place-kind name-key suppression on the very name the reader was trying to keep, inverting the correction Q11 built the control for. Req 2.2 and Q31 name the deduped-away case only. Amended at the pre-push review: an unstruck proposed alias also keeps Keep enabled, because Q36 counts a new alias as content worth showing and an alias-only reclassify would otherwise be reachable only through the editor |+| Q81 | 2026-09-11 | Staging a conversion dismisses the record editor sheet; the take-back is reached by reopening the line from the other card | The sheet is parameterised by kind at construction, so a sheet left open after a convert would say "Delete character" over a draft the reader has just made a place. Dismissing is what makes "the line moves cards" visible and keeps one sheet to one presentation; the take-back label is unchanged, one tap further away |+| Q82 | 2026-09-11 | Task 23's "`AppLibraryModel` conflict routing passes `.place`" is dropped: nothing produces a record write-conflict | `recordConflict` has three call sites, all `.entry` or `.work`, fed by the entry and work detail models' `onConflict`. A record edit refusal travels as `RecordEditRefusal` out of `commitRecordEdits` and is rendered by the work detail model's refusal message; it never reaches `onConflict`. The bullet named a seam that does not exist |+| Q83 | 2026-09-11 | The wide-layout place case runs at the default text size; the accessibility-size half of Reqs 2.2 and 4.1 is the phone journey's | At `accessibility5` the iPad opens with the sidebar collapsed (pinned by `WideLayoutAccessibilityUITests`), so the journey cannot reach Works without a sidebar toggle first. The wide case earns its keep on containment and the size coverage on the phone, which runs at `accessibility5` |+| Q84 | 2026-09-11 | Req 3.5's collapse repointing reaches only records their work resolves for; an orphan keeps a dangling citation | `rows(of:)` is what keeps the phase off a whole-table read the reconciler's budget cannot afford, and an orphan already degrades in display rather than failing (Req 5.5). Exact parity with the character side, whose nil-work rows are equally unreachable |+| Q85 | 2026-09-11 | The review sheet's kind control emits one accessibility identifier per segment, `character-review-kind-<rowid>-<kind>`, beneath the container's `character-review-kind-<rowid>` | `ConstellationSegmentedControl` (Q79) renders each segment as its own element, so a journey taps a segment, not the container; the container identifier the design names is kept for presence checks. The spelling lives only in `RecordKindPresentation.reviewControlIdentifier` |+| Q86 | 2026-09-11 | The manual-pass row reads "Look for characters and places" on every work, including one with no places; Req 4.1's "no place-related element" is read as no place data | One manual pass covers both kinds (Q4), so the row is a control over the pass, not a place element; a work with no places still shows no Places section, no place card and no place count. Documented in the design doc as the designed exception |  ## Decision 3: The store is generic over a record row; the pipeline is kind-aware 
specs/place-extraction/design.md Modified +5 / -5
diff --git a/specs/place-extraction/design.md b/specs/place-extraction/design.mdindex dff70bb..c4aaeca 100644--- a/specs/place-extraction/design.md+++ b/specs/place-extraction/design.md@@ -102,7 +102,7 @@ Assembler, per pass: 3. Group the surviving candidates by `(kind, nameKey)` across the pass's sources, as today, unioning `returnedKinds` where a dual copy and a plain copy of one kind meet. 4. Sort, cap per kind. -A skip that suppresses under both kinds (a dual row with no target) also discards every other held row of that name key for the work, whichever kind it displays: the reader skipped the name under both kinds, and a "Bay — Place" row from another source surviving that skip would contradict it (Q56). The coordinator does this in `discard`, keyed by name key across kinds, on the decision's `returnedKinds`. A discarded row contributes no `completedSources`, so a source that fed only it stays uncovered, is re-read once, filters to nothing under the new suppression and settles `producedNone` — self-healing, and the log shows it once.+A skip that suppresses under both kinds (a dual row with no target) also discards every other held row of that name key for the work, whichever kind it displays: the reader skipped the name under both kinds, and a "Bay — Place" row from another source surviving that skip would contradict it (Q56). The coordinator does this in `discard`, keyed by name key across kinds, on the kinds the commit actually wrote a name-key suppression under, which the decision request exposes as `nameKeySuppressedKinds` and which is empty for anything but a candidate skip (Q77). A discarded row contributes no `completedSources`, so a source that fed only it stays uncovered, is re-read once, filters to nothing under the new suppression and settles `producedNone` — self-healing, and the log shows it once.  `ExtractionProposal` gains `kind` (as assembled), `displayedKind` (reader override, initially `kind`), `returnedKinds`, and `Target` becomes `.newRecord | .existing(UUID)`. The ledger keys `held` rows by `ProposalKey(kind, nameKey)` — the *assembled* kind, so a toggle never changes a row's identity (Q28) — and `hold`/`merged`/`discard`/`retarget` take that key; `merged` unions `returnedKinds`, and when the older row carries a reader override it keeps that `displayedKind` and re-runs the reclassify preview under it over the merged content, replacing the newer row's target — a merged row never inherits a target resolved under the assembled kind; a new `reclassify(key:for:to:target:)` writes `displayedKind` and the previewed target. `WorkExtractionState` carries `recordIDs: [RecordKind: Set<UUID>]`. @@ -112,7 +112,7 @@ A skip that suppresses under both kinds (a dual row with no target) also discard  `CharacterReviewRow` gains `kind` (displayed), `originalKind`, `canReclassify` (candidates, and bundles that reclassification produced), `isDualKind`, and `crossKindHint: String?`. `CharacterReviewModel`: -- `reclassify(_ rowID:, to kind:)` — previews the match at once: the model already holds `characters` and `places` presentations, so it computes the tiers (current-name key, retained key, alias keys, lowest UUID) against the new kind's presentations, redraws the row as a bundle with that record's existing facts when one matches or as a candidate when none does, re-keys and re-dedups the row's facts under the new kind, keeps ticks and strikes, and calls `coordinator.reclassify(...)` so the held row remembers. Accepted identities come from the presentations; suppressed identities come from the coordinator, which now retains each work's last candidate read as `contexts: [UUID: ExtractionContext]` (per-kind accepted facts and suppression index), refreshed by every pass over the work and by `reconcile()`. A stale index (a suppression synced in since the read) affects the preview only: the commit dedups against the store, and accepting a ticked fact clears its suppression (Req 2.4). The row stays even with no facts left, accept disabled. The name key is not re-checked against the new kind's suppressions (Q31). The preview's result is a **projected proposal** held on the row — facts re-keyed and re-deduped under the destination kind and target, aliases and cited revisions unchanged — and `decide` builds the decision request from that projection, never from `row.proposal`. Ticks and strikes are carried by proposal-local index rather than by `RecordFactIdentity`, because identity is `(nameKey, source, quote)` and re-keying changes it (Q59).+- `reclassify(_ rowID:, to kind:)` — previews the match at once: the model already holds `characters` and `places` presentations, so it computes the tiers (current-name key, retained key, alias keys, lowest UUID) against the new kind's presentations, redraws the row as a bundle with that record's existing facts when one matches or as a candidate when none does, re-keys and re-dedups the row's facts under the new kind, keeps ticks and strikes, and calls `coordinator.reclassify(...)` so the held row remembers. Accepted identities come from the presentations; suppressed identities come from the coordinator, which now retains each work's last candidate read as `contexts: [UUID: CharacterExtractionContext]` (per-kind accepted facts and suppression index), refreshed by every pass over the work and by `reconcile()`. A stale index (a suppression synced in since the read) affects the preview only: the commit dedups against the store, and accepting a ticked fact clears its suppression (Req 2.4). The row stays even with no facts left, accept disabled. The name key is not re-checked against the new kind's suppressions (Q31). The preview's result is a **projected proposal** held on the row — facts re-keyed and re-deduped under the destination kind and target, aliases and cited revisions unchanged — and `decide` builds the decision request from that projection, never from `row.proposal`. Ticks and strikes are carried by proposal-local index rather than by `RecordFactIdentity`, because identity is `(nameKey, source, quote)` and re-keying changes it (Q59). - `crossKindHint` — set when the row's name key matches a record of the *other* kind by the same tiers: "You already have a character with this name" / "… a place …" (Q41). Presentation only. - Decision path unchanged; the repository re-verifies the displayed target under the displayed kind (Q24) and refuses `.reRouted` as today. @@ -122,7 +122,7 @@ A skip that suppresses under both kinds (a dual row with no target) also discard  `WorkDetailModel` keeps one draft set with a kind on each draft: `recordDrafts: [UUID: RecordDraft]` (`RecordDraft.kind`), `recordBases`, `stagedRecordOperations` (`create(id:kind:)`, `delete(id:)`, `combine(source:target:)`, `convert(id:to:)`). `characterDrafts`/`placeDrafts` are filtered views for the two cards. `commitEditing`'s order becomes URL → metadata (`reloading:` false when any record step follows) → **one** `commitRecordStep` → `onMutation()` → one `load()`. The `onMutation()` call after a committed record step is new: it is what reaches `refreshDiagnosesAndSnapshots` and so the coordinator's `reconcile()`, which drops held bundles targeting a deleted or converted record. Today a delete with no metadata change leaves that bundle until the next arrival; the commit gate (`.reRouted`) already keeps it harmless, and this makes the discard immediate (Q55). -Conversion (Req 3.7, Decision 1) is staged as `.convert(id:to:)`: the editor shows the record under its new kind immediately (the draft's `kind` flips, the line moves cards), and Save sends it as one `RecordEditOperation.convert(basis:to:draft:)` carrying the draft as it stands at Save — so edits made after the convert ride on the convert, and `stagedRecordEdits` emits no `.update` for a converted record. Combine is unavailable in-session for a converted record, as it is for one created in the session (the button is hidden; the target list excludes it), and a record converted then deleted in one session is a plain delete (Q54). Inside `commitRecordEdits`, still one save: read the source group under its kind, verify the basis, insert a row of the other kind via `make(...)` with a fresh UUID carrying the draft's name, aliases, note and facts (re-keyed to the same retained key, which the new row also carries as `nameKey`), attached to the work, write the carried facts' triple suppressions under the old kind keyed to the retained key (no name-key suppression, Q35), clear under the new kind the union of the verified basis's retained, current and alias name keys and the draft's name and alias keys, plus the carried facts' triples, delete the source group's rows. A torn source refuses `.torn` (Q39).+Conversion (Req 3.7, Decision 1) is staged as `.convert(id:to:)`: the editor sheet dismisses and the line moves cards at once (the draft's `kind` flips; the take-back is reached by reopening the line from the other card, Q81), and Save sends it as one `RecordEditOperation.convert(basis:to:draft:)` carrying the draft as it stands at Save — so edits made after the convert ride on the convert, and `stagedRecordEdits` emits no `.update` for a converted record. Combine is unavailable in-session for a converted record, as it is for one created in the session (the button is hidden; the target list excludes it), and a record converted then deleted in one session is a plain delete (Q54). Inside `commitRecordEdits`, still one save: read the source group under its kind, verify the basis, insert a row of the other kind via `make(...)` with a fresh UUID carrying the draft's name, aliases, note and facts (re-keyed to the same retained key, which the new row also carries as `nameKey`), attached to the work, write the source's whole stored fact set as triple suppressions under the old kind keyed to the retained key (Req 3.7's "its deleted facts", which is the carried set only when the reader dropped none) (no name-key suppression, Q35), clear under the new kind the union of the verified basis's retained, current and alias name keys and the draft's name and alias keys, plus the carried facts' triples, delete the source group's rows. A torn source refuses `.torn` (Q39).  `CharacterEditorView` takes `kind`; strings come from a `RecordKind` copy table (title "New place", "Delete place", "Combine into…" unchanged, torn notice unchanged). The third structural action, `ConstellationFooterButton("Make this a place" / "Make this a character", systemImage: "arrow.left.arrow.right")`, sits between Combine and Delete; it is hidden for a torn record and for a draft created in this session (create-then-convert is just create under the other kind), and a converted record shows "Make this a character again" in its place, which un-stages the convert. @@ -265,7 +265,7 @@ Both design gates were measured on the host against the frozen corpus (Q45) with  ## Risks and Assumptions -- Assumption: generic repository code over `RecordRow: PersistentModel` performs at the M4 fixture's scale as the character code does | Verify: the compile-and-run shape is proved by `prototype/generic-store-spike/`; the reconcile arm of `M4DuplicateScalePerformanceTests`, with places seeded, is the performance check | If wrong: the generic body stays and the conformance's fetch is what gets tuned.+- Assumption: generic repository code over `RecordRow: PersistentModel` performs at the M4 fixture's scale as the character code does | Verify: the compile-and-run shape is proved by `prototype/generic-store-spike/`; `placeRankingAtScale` in `M4ScalePerformanceTests` is the performance check; the reconcile fixture seeds no characters, so it seeds no places either (Q74) | If wrong: the generic body stays and the conformance's fetch is what gets tuned. - Risk: the combined request's phone timing exceeds 125% of character-only (Req 6.1; the host signal is 125%, on the bar) | Verify: `Development` comparison on the phone — the agent installs each build and captures the `CharacterExtraction` log, the owner imports the archive and taps the manual pass (`prerequisites.md`); recorded in `verification-run.md` | If wrong: lower the place cap, then shorten the place paragraph of the instructions; the timeout and budget stay (Req 6.1). - Risk: the combined schema fits fewer tokens, so more sources overflow the context window (1 of 125 in the corpus crossed the line under arm B only) | Verify: the overflow count in the `CharacterExtraction` log over the first weeks of real sweeps | If wrong: the oversized-source skip already handles it; the manual pass is the way back, and a smaller place cap is the lever. @@ -284,4 +284,4 @@ Both design gates were measured on the host against the frozen corpus (Q45) with - **Ranking**: `RecordRanking` over `PlaceGroup` at the 200×50×500 scale, the Req 6.2 arm. - **Work detail / entry detail reads**: places ranked, name-order citing list, both in the single locked read. - **UI**: canned result with a place and a dual-kind name; indicator counts both; reclassify a character row to place and keep; hint shown for an accepted character's name; convert in the editor and see the line move cards; places section and entry-detail section; manual pass label.-- **Performance**: `M4ScalePerformanceTests` gains `placeRankingAtScale`; the reconcile arm's fixture seeds places at the character density so the repoint cost is measured.+- **Performance**: `M4ScalePerformanceTests` gains `placeRankingAtScale`. The reconcile arm's fixture is unchanged: it seeds no characters, so places at the character density is zero (Q74); collapse repointing over both kinds is covered for correctness by `PlaceDuplicateMachineryTests`.
specs/place-extraction/implementation.md Added +447 / -0
diff --git a/specs/place-extraction/implementation.md b/specs/place-extraction/implementation.mdnew file mode 100644index 0000000..fa7886f--- /dev/null+++ b/specs/place-extraction/implementation.md@@ -0,0 +1,447 @@+# Implementation: Place Extraction++## Beginner Level++### What Changed++Asterism already reads a reader's chapter notes with Apple's on-device model and+proposes the *characters* those notes name. The reader reviews each proposal and+either keeps it or skips it. This branch teaches the same pass to propose+*places* as well, and gives places the same life a character has: a name,+alternative names, a free-text note, cited facts, an editor, and a section on the+work page.++Four things happen for the reader:++1. One pass, two kinds. The single request the app sends the model for each note+   now asks for characters and places together, so nothing gets slower and no+   second pass appears.+2. One review list. Characters and places are reviewed in the same sitting, each+   row labelled with what it is.+3. A correction the reader can make. The model frequently files a place as a+   character. A row now carries a Character/Place switch, so the reader can+   re-file a proposal before keeping it.+4. A rescue for records already kept under the wrong kind. Inside the work+   editor there is a new action, "Make this a place" (or "Make this a+   character"), that moves a record across with its facts, citations and note+   intact.++Underneath that, the app's database grew two new tables, the backup file format+moved a generation, and about two thousand lines of character-specific storage+code were rewritten once, generically, so that places reuse it rather than+copying it.++### Why It Matters++The reader has been keeping extracted characters since August, and a good number+of the things they kept are places. Deleting a mis-filed character and typing a+place by hand would throw away the facts, the quotes and the links back to the+notes those facts came from, which is the entire value of the record. The+conversion action keeps all of it.++The shared model request matters for a different reason. On-device model time is+the scarce resource in this app, and the reader waits for it. A second pass for+places would have doubled that wait. Asking one question that returns two answers+costs almost nothing extra, which is what Requirement 6.1 exists to prove on real+hardware.++### Key Concepts++**Schema.** The shape of the app's local database: which tables exist and what+columns each has. Think of it as the blueprint of a filing cabinet. This branch+moves the blueprint from version 12 (V12) to version 13 (V13) by adding two+drawers, `Place` and `PlaceSuppression`.++**Migration.** Rebuilding an existing filing cabinet to a new blueprint without+losing what is in it. Because V13 only *adds* drawers and changes nothing+existing, the migration is what Apple calls "lightweight": the system handles it+with no custom code, and every existing row is untouched.++**Readiness marker.** A tiny file the app writes saying which blueprint the+cabinet is on. The share extension (the thing that catches a page you share into+Asterism) reads that marker and refuses to open a cabinet it does not understand,+which is what keeps it from ever attempting a migration of its own. The marker+moved from "12" to "13".++**CloudKit sync.** Apple's service that copies the database between the reader's+devices. It is not a backup and there is no custom sync engine; the app hands+SwiftData a container and SwiftData mirrors rows.++**Name key.** A normalised, lower-cased form of a record's name, used for+matching. "Terawatt", "terawatt" and " Terawatt " all share one key. The key is+minted once when a record is created and is then *retained*: renaming the record+does not change it, so a proposal the model makes later still finds the record.++**Suppression.** A remembered "no". When the reader skips a proposal, the app+writes a row saying "do not propose this name for this work again". Suppressions+are per kind here: skipping the place "Bay" must not silence the character "Bay".+That is why places got their own suppression table rather than a flag on the+existing one.++**Convergence.** The repair mode for sync. Two devices can end up holding two+database rows carrying the same record UUID. Convergence makes those rows+identical in place and deletes nothing. Where the two rows disagree about+something the reader wrote, the record is "torn" and the reader is asked which+version to keep.++**Orphan.** A place whose owning work cannot be found, usually because the place+synced in before the work did. A place names its work by UUID rather than by a+database relationship, so an unresolvable owner is a dangling number rather than+a broken link. Orphans are tolerated, displayed nowhere, exported as they are,+and never deleted by a sweep. The moment the work arrives, the place appears+under it.++---++## Intermediate Level++### Changes Overview++Thirty-nine commits, 176 files, in five phases, each phase a run of red/green+pairs: a commit of failing tests, then the commit that makes them pass. Red+commits are labelled as such in their messages and are allowed not to compile+against the green half's types, because the branch squash-merges (Q75).++Three layers took three different treatments.++*Store (`AsterismCore`).* A new protocol `RecordRow` in `RecordRow.swift`+abstracts a named record with cited facts, and `SuppressionRow` does the same for+the two suppression tables. `CharacterRecord`, `CharacterSuppression`, `Place`+and `PlaceSuppression` conform. Everything mechanical over the tuple (name, key,+aliases, note, facts, work) became generic: `RecordGroup<Row>`+(`RecordGroups.swift`), `RecordFact`/`RecordFactCodec` (`RecordFacts.swift`),+`CitationRepointing`, `RecordRanking`, `WorkRecordPresentation`, the decision+commit in `LibraryRepository+RecordExtraction.swift`, the edit commit in+`LibraryRepository+RecordEditing.swift`, and the archive merge in+`BackupImportRecords.swift`. Old names survive as typealiases where call sites+read better for it.++*Pipeline (`AsterismIntelligence`) and coordinator (app).* Not generic. A+`RecordKind` field threads through `ExtractionResult`, `GroundedCandidate`,+`ExtractionProposal`, `ProposalKey`, `DecisionRequest` and the ledger. The type+names keep their `Character…` prefix, because there is one pipeline and renaming+it would buy churn (Q40).++*Views.* Parameterised by kind through one copy table,+`RecordKindPresentation.swift`. New elements exist only where the feature is new:+the review row's kind control, the cross-kind hint, and the editor's convert+action.++### Implementation Approach++**Why generic over a protocol rather than a kind switch or a copy.** Decision 3.+The store code is identical for both kinds and a copy would be two thousand lines+kept in step by hand. A `switch kind` in the repository spreads into every+function that touches a row. The one real constraint on generic SwiftData is that+a `#Predicate` cannot be written against a protocol-typed key path, so generic+code writes none: it calls `Row.rows(of:context:)` or `Row.rows(ids:context:)`+and each conformance answers the way its own table must be read. Characters walk+their inverse relationship; `Place` runs one `#Predicate { ids.contains($0.workID) }`+fetch for every work handed in and groups in memory. The shape was proved in a+standalone package (`prototype/generic-store-spike`) before the design was+accepted (Q58), which is also where the `recordID` naming came from: `PersistentModel`+already vends `id: PersistentIdentifier`, so a generic `row.id` is ambiguous (Q52).++**Why UUID columns rather than relationships.** `Place.workID` and+`PlaceSuppression.workID` are non-optional defaulted `UUID` columns, the+`WorkCredit` shape (Q44). This is CLAUDE.md's standing rule, now made a fourth+time. An inverse faults every row on the other side, and a `.nullify` on an+absent target would erase an owner that must survive as unresolved while the work+is still in transit. The cost is that orphan-ness means "no work resolves"+rather than "the link is nil", which the protocol states explicitly through+`ownerWorkID`.++**Why a separate suppression table.** Decision 2, and it is a sync argument, not+a storage one. Both configurations mirror to CloudKit, and during an update+window a pre-feature build shares the container. To such a build, a place+suppression carrying a hidden record-kind column is an ordinary character+suppression with the same tuple, so accepting a character "Bay" there would+*clear* a place suppression it cannot see. That is an unauthorised write.+A record type a build does not know about is ignored entirely.++**The dual-kind rule.** One response can return the same name under both kinds.+`CharacterExtractionAssembler.applyDualKindRule` runs after per-kind filtering,+over the survivors of *one source response* only. Both unmatched folds to one row+displayed as a character carrying the union of facts, marked as returned under+both kinds. Exactly one matched yields a bundle plus a candidate, neither marked+dual. Both matched yields two bundles.++**The projection.** Reclassifying a row does not mutate the held proposal. It+computes a *projected* proposal (`CharacterReviewModel.project`) with the facts+re-keyed to whatever the new kind resolves onto and re-deduped against that+kind's accepted and suppressed sets, and the decision request is built from the+projection, never from the held row (Q59). Ticks are carried across by+`RecordFact.displayRowID`, which deliberately excludes the name key (Q78).++**Conversion as an operation.** `RecordEditOperation.convert(basis:to:draft:)`+rides in the same `commitRecordEdits` list as create, update, delete and combine,+applied in the order the reader performed them, in one save. It is the only+operation that touches two tables, which is why `commitRecordEdits` reads both+tables up front whether or not the session opened both.++### Trade-offs++The renames reach files that are not otherwise changing, and the archive record+types keep generation-scoped names beside the generic ones. The generic ranker+costs measurably more than the concrete one it replaced (see the expert level).+Conversion mints a fresh UUID, so restoring an archive taken before a conversion+recreates the original beside the converted record, and that pair is the reader's+to clean up. Conversion also does not carry the old kind's active fact+suppressions into the new table (Q73), so an unticked fact can be re-proposed+once under the new kind.++---++## Expert Level++### Technical Deep Dive++**The marker move is the real compatibility boundary, not the schema.**+`appOpenableMarkerVersions` is `{"12","13"}` and `extensionOpenableMarkerVersion`+is `"13"`. No new `BootstrapState` case was added: `markerLagging(generation:)`+already carries the digit (Q64). The consequence is one-directional. Once the app+has published `"13"`, a pre-feature build refuses the store by name, so a+downgrade is not a recovery path; the archive is. Between the app converting and+the extension next running, a share lands in the pending-capture queue rather+than in the store, which is what carries Req 5.6. `AsterismSchemaV11` and its+recorded-store fixture were deleted in the same commit as the freeze, because a+fixture opening a deleted snapshot does not compile (Q65), and the population+precondition was verified before the freeze rather than after it.++**Ownership on the import update path is the one place last-writer-wins does not+reach.** `modifiedAt` guards content; ownership has no timestamp, and the work+merge moves `workID` without touching `modifiedAt`. So `attach` gained a third+parameter, the resolution map, and the update path passes it: an archived+`workID` is adopted only when it resolves here, or when the local row is itself+an orphan with nothing to lose (Q76). Without that, re-importing an archive taken+before a work merge would move a place onto a work the payload does not carry and+make it invisible everywhere. The insert path keeps the two-argument spelling,+whose empty map answers "nothing resolves", so the orphan round trip is+unchanged. The character side cannot reach the bug, because its `attach` leaves+an existing relationship alone.++**Place collapses are narrower than they look.** `DuplicateScan.recordSets`+buckets by UUID only, so every place set has exactly one member and there is no+distinct-UUID loser to delete; places converge and never collapse. The+write-before-delete and settling-ledger fencing they inherit is the Entry and+Work collapse path, where `CitationRepointing.repoint` runs over both kinds+inside one throwing scope before the save, so a failed place fetch rolls the+character rewrite back with it. Q84 records the limit: repointing reaches only+records whose work resolves, because the phase reads `rows(of:)` rather than the+whole table, so an orphan keeps a dangling citation. Exact parity with the+character side, whose nil-work rows are equally unreachable.++**The cross-kind sweep has one gate, exposed once.** `DecisionRequest.suppressedKinds`+is never empty, and sweeping the coordinator's held rows on it would discard a+legitimate same-name row of the other kind after an *accept*, which Req 2.4+forbids. `nameKeySuppressedKinds` is the gated reading: it returns the empty set+unless `action == .skip && displayedTargetID == nil`. Both consumers, the+repository's `suppressUnderOtherKinds` and the coordinator's `discard`, take that+one accessor, so neither can reach for the natural but wrong field (Q77).++**The ranking arm is the generic tax, measured.** `character-ranking-200x50`+moved from 0.00246 s to 0.00368 s, roughly +50%, while every other arm in the+same run fell by 3 to 10 percent on a quieter host. That is real, and it is the+cost of `CharacterRanking` becoming one generic `order` over `RecordRow`, with+`facts` promoted from a model property to a protocol requirement satisfied by an+extension (Q63) so a conformance cannot shadow it and the decode has one+canonical implementation. `RecordRanking` already carries the facts out of the+group alongside the ranked order so the work-page open does not decode the cast+twice. `place-ranking-200x50` measures 0.003547 s, 3.6 percent *under* its+sibling, which is the point: the second conformance costs what the first does.+Both sit near 35 percent of the 10 ms budget, and no bound was adjusted.++**A second install on the dev container is the case Decision 2 was written for.**+Both configurations mirror, so a `Development` install is not device-local. A+pre-feature build on a second device sees two record types it has no model for+and ignores them; it cannot read a place suppression as a character suppression,+and cannot clear one. What it *can* do is delete or merge a work, leaving the+place rows behind as permanent orphans. That is accepted and bounded by the+update window. Task 26's publication run exists because+`NSPersistentCloudKitContainer` publishes record types lazily: a `Development`+run has to push the two new types to the dev container first.++### Architecture Impact++Every store behaviour now has one implementation, so a place bug and a character+bug are the same bug, and the character suites became the place suites by+parameterising over `RecordKind.allCases`. CLAUDE.md was amended in three places+that matter beyond this feature: the "no relationship since V6" rule now names+V7's pair as the exceptions, the UUID-column rule is recorded as made four times,+and the edit-view rule lists conversion alongside combine and delete as+staged-on-Save. The T-2328 violation list did not grow.++### Potential Issues++- `dedupe-links-noop` measured 0.010032 s, 32 microseconds over a 10 ms budget it+  is an accepted known issue for. The known issue is not `isIntermittent`, so a+  quieter host that lands under 10 ms turns it into a second way to be red.+- Guardrail refusals were 7 for the combined request against 6 for the+  character-only one, exactly at the bar as amended (Q62), with no headroom.+  Refusals are a classifier over instructions plus note text, so a future+  instruction edit can move it.+- The combined schema fits fewer tokens: one of 125 corpus sources crossed the+  context window under the combined arm only. The oversized-source skip handles+  it, but the overflow rate in real sweeps is unmeasured.+- Conversion plus sync can resurrect the source. A pre-feature build holding an+  offline edit to the converted-away character re-creates it after the delete.+  Decision 1 names this; there is no tombstone.+- Q73's consequence is visible once: an unticked fact does not carry its+  suppression across a conversion, so it can be re-proposed under the new kind.+- `mergeImportedRecords` opens with a whole-table fetch per kind, now four rather+  than two. That matches the Work and Entry steps for a bulk path.++---++## Completeness Assessment++### Fully implemented++- **1.1** One request returns both kinds. `ExtractionResult.characters` and+  `.places` in `ExtractionResult.swift`; bounds, lane class and failure handling+  untouched in `CharacterExtractionBounds.swift`.+- **1.2** Grounding runs one rule set over both arrays:+  `CharacterGrounding.ground(_:kind:cap:)`, with `dropReason` shared and the+  capital-letter rule applied to places unchanged (Q42). `CharacterGroundingTests`.+- **1.3** `CharacterExtractionBounds.maximumPlaceCandidates = 12`, separate from+  `maximumCandidates = 24`; an undecodable response is a failed attempt (Q26).+- **1.4** Coverage stays one record per source revision; `CompletedSource` and+  `producedNone` unchanged, filtered copies counting as decided (Q25).+- **1.5** `CharacterExtractionAssembler.applyDualKindRule`, all four arms, per+  source response only.+- **1.6** Per-kind dedup through `ExtractionCandidate`'s per-kind `records`,+  `acceptedFacts` and `suppressions` dictionaries.+- **1.7** The slash split runs inside `groundName(_:in:kind:)` for both kinds;+  reclassification re-runs matching through `CharacterReviewModel.project`.+- **1.8** Measured at design against the frozen corpus: R = 11 of 252 (4.4%,+  bar 10%), no accepted character lost, refusals 7 against 6 at the amended bar+  (Q62). `prototype/prototype-findings.md`. The shipped instructions are that+  run's text.+- **2.1** `proposalsIndicatorSection` counts held rows of both kinds; the review+  sheet labels each section by kind and discloses a union row.+  `testTheSweepRaisesAnIndicatorAndTheListPresentsBothProposals`.+- **2.2** `CharacterReviewModel.reclassify`, ticks carried by `displayRowID`,+  row never removed, `canAccept` per Q80, chosen kind held in the ledger.+  `testReclassifyingACharacterRowKeepsItAsAPlace`.+- **2.3** Preview at toggle time plus commit re-verification; `.reRouted` refusal+  evaluated under the displayed kind. "Re-routing is evaluated under the+  request's kind".+- **2.4** `commitDecision` writes under `request.kind` only, with+  `suppressUnderOtherKinds` the single exception. Four named arms in+  `CharacterExtractionRepositoryTests` cover isolation, the dual-kind skip, the+  reclassified single-kind skip and the accept clearing only its own kind.+- **2.5** Decisions commit outside the edit transaction; the indicator is absent+  in edit mode (`testTheProposalsIndicatorIsAbsentInEditMode`).+- **3.1** `Place` in `AsterismSchemaV13`, full character shape, retained key.+- **3.2** Edit-mode create, edit and delete through `commitRecordEdits`;+  "Add a place" footer; hand-creation clears a standing candidate suppression.+  `testThePlacesCardOpensThePlaceEditorFromItsLineAndItsFooter`.+- **3.3** Combine is same-kind only; a cross-kind combine refuses+  `.kindMismatch` ("A combine across kinds refuses .kindMismatch").+- **3.4** Deletion suppresses the group's deletion key set and its fact triples+  through the generic `deletionKeys(of:)`.+- **3.5** Work deletion, merge and entry collapse all have place arms, each with+  a named test in `PlaceDuplicateMachineryTests`.+- **3.6** A dangling citation keeps its text and span and is never an integrity+  error; covered by the generic presentation and `WorkDetailReadTests`.+- **3.7** `convert` in `LibraryRepository+RecordEditing.swift`: new UUID,+  retained key carried, source triples suppressed under the old kind with no+  name-key suppression, destination clears computed over the basis, current and+  draft key sets, source group deleted whole, torn refusal. Five parameterised+  tests plus `testConvertingInTheEditorMovesTheLineAndSurvivesSave`.+- **4.2** "A place's facts carry their citations in capture order (4.2)".+- **4.3** "Places rank over the work's places alone, name order among equals (4.3)".+- **4.4** "Entry detail names the places citing that entry, in name order".+- **5.1** `BackupV12` at (12, 13), 11/12 refused by name, `places` and+  `placeSuppressions` arrays, orphan place round trip, re-recorded+  `backup-12-13-golden.json`, and a new test tying the archive's schema version+  to the live schema.+- **5.3** Same-UUID place convergence, distinct-UUID sets never formed, torn+  place disclosed and refused by the exporter. Four named tests.+- **5.4** Suppressions converge by `actionAt` with clear beating an equal time,+  through the generic `resolvedSuppressions`/`suppressionPrecedes`.+- **5.5** Orphan tolerance on every path: "An orphaned place forms no set and+  survives a reconcile pass", "A place whose work a pre-feature build deleted is+  a permanent orphan", "rows(of:) hides an orphan and rows(ids:) reaches it".+- **5.6** `V12RecordedStoreTests`: "openForApp converts to 13.0.0, adding two+  empty tables", plus the extension refusal that routes a share to the pending+  queue.+- **6.2** `place-ranking-200x50` at 0.003547 s against a 10 ms budget and a 50 ms+  ceiling (`verification-run.md` §2).++### Partially implemented++- **4.1** The Places section, its pills and its edit-mode card are all in place,+  and a work with no places shows no place *section*. The manual pass row was+  relabelled "Look for characters and places" and stays in the Characters+  section, so a place-less work does show one place-related string. The design+  specifies the relabel and task 27 records it as a designed exception, but no+  decision-log row reconciles it with Req 4.1's wording.+- **5.2** The code half is done and is what Decision 2 is about: a separate+  record type a pre-feature build cannot read or clear. The publication of+  `Place` and `PlaceSuppression` to the dev container, and the confirmation that+  existing record types keep syncing, are task 26 and have not run.++### Missing or pending++- **6.1** The phone comparison is task 26 and is an owner step. The host signal+  over the prototype corpus was median 125% and p90 123% of the character-only+  request, exactly on the 25% bar. The gate is unmeasured on device, and the+  levers if it fails are named in the design: lower the place cap first, then+  shorten the place paragraph.+- Task 27 is otherwise complete (CLAUDE.md, `schema-migration.md`,+  `testing.md`, the design and style docs, `specs/OVERVIEW.md` and `CHANGELOG.md`+  all moved), but its `verification-run.md` line for Req 6.1 cannot be written+  until task 26 runs.++### Divergences from the design++Recorded, with the row that records each:++- `facts` moved onto `RecordRow` as a requirement, and two more files were+  renamed with their types (Q63).+- No new `BootstrapState` case; `markerLagging(generation:)` carries `"12"` (Q64).+- The schema-reference rename landed in the freeze task, not the one after (Q65).+- `Place`/`PlaceSuppression` borrowed the character archive records until the+  codec rename (Q66).+- `Place.make(work: nil)` leaves a freshly minted `workID` (Q67).+- Req 1.5's survival predicate keeps `character-extraction`'s existing+  no-facts-left exception, which is narrower than Req 1.5 and Q36 as written (Q68).+- The ledger's `merged` keeps the reader's kind and the older target; the preview+  re-runs in the review model rather than in the ledger (Q69).+- The dual-kind fold carries facts only, not the folded copy's aliases (Q70).+- `CharacterExtractionSource` kept its name against the rename table, to avoid+  colliding with `AsterismIntelligence`'s existing `ExtractionSource` (Q71).+- `CharacterExtractionContext`'s character-only initialiser moved to a test-side+  extension rather than being deleted (Q72).+- `.convert` ignores `draft.kind`; `to:` is the authority, and conversion does+  not carry fact suppressions across kinds (Q73).+- The M4 reconcile fixture seeds no places, and the design's reconcile-arm bullet+  was dropped (Q74).+- `attach` gained a resolution-map parameter and the import update path narrows+  ownership adoption (Q76).+- The cross-kind sweep reads one gated `nameKeySuppressedKinds` accessor rather+  than `returnedKinds` at each consumer (Q77).+- Ticks carry by `displayRowID`, not by the proposal-local index Q59 named (Q78).+- The kind control is `ConstellationSegmentedControl`, not a `.segmented`+  `Picker` (Q79).+- `canAccept` stays true for a reclassified candidate the model reported with no+  facts, or with an unstruck proposed alias (Q80).+- Staging a conversion dismisses the editor sheet; the take-back is reached by+  reopening the line from the other card (Q81).+- The `AppLibraryModel` conflict-routing bullet was dropped as naming a seam that+  does not exist (Q82).+- The wide-layout place case runs at the default text size (Q83).+- Collapse repointing reaches only records whose work resolves (Q84).++Divergences with no row:++- The review sheet's kind-control identifier is+  `character-review-kind-<rowid>-<kind>`, one per segment, where the design names+  `character-review-kind-<rowid>`. It follows from Q79's shared control needing a+  per-segment identifier, but Q79 does not say so and the design's spelling is+  what a UI test would be written against.+- The Req 4.1 tension described under "Partially implemented": the relabelled+  manual-pass row is a place-related element on a place-less work. The design+  specifies it and task 27 documents it, but nothing in the decision log records+  the requirement being read that way.
specs/place-extraction/tasks.md Modified +39 / -32
diff --git a/specs/place-extraction/tasks.md b/specs/place-extraction/tasks.mdindex 1ac78dd..b26863a 100644--- a/specs/place-extraction/tasks.md+++ b/specs/place-extraction/tasks.md@@ -8,7 +8,7 @@ references:  ## Generic store -- [ ] 1. Write failing tests for the RecordRow protocol on Character and the generic group, repointing and ranking <!-- id:jsrm210 -->+- [x] 1. Write failing tests for the RecordRow protocol on Character and the generic group, repointing and ranking <!-- id:jsrm210 -->   - `RecordRowTests`: `CharacterRecord.rows(of:)` walks the inverse and dedups by object identity; `rows(ids:)` is a predicate fetch; `recordID` is the app UUID; `make(imported:)` then `attach(to: nil, archivedWorkID:)` leaves an existing relationship alone.   - `RecordGroupTests` over `RecordGroup<CharacterRecord>`: same-UUID convergence, torn variants, presented content, `createdAt`/`modifiedAt`; `CitationRepointing.repoint` fans out to every row; `RecordRanking.rankGroups` over the group type yields the order `CharacterRankingTests` asserts today.   - The existing character suites are the regression net for the renames; nothing in them changes meaning, only names.@@ -17,7 +17,7 @@ references:   - Requirements: [5.3](requirements.md#5.3)   - References: Packages/AsterismCore/Sources/AsterismCore/CharacterGroups.swift, Packages/AsterismCore/Sources/AsterismCore/CharacterFacts.swift, Packages/AsterismCore/Sources/AsterismCore/CharacterRanking.swift, Packages/AsterismCore/Tests/AsterismCoreTests/CharacterDuplicateMachineryTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/CharacterRankingTests.swift, specs/place-extraction/prototype/generic-store-spike/Sources/main.swift -- [ ] 2. Introduce RecordKind, RecordRow and SuppressionRow, rename the store types and make groups, repointing, ranking and presentations generic <!-- id:jsrm211 -->+- [x] 2. Introduce RecordKind, RecordRow and SuppressionRow, rename the store types and make groups, repointing, ranking and presentations generic <!-- id:jsrm211 -->   - `RecordKind.swift` in AsterismCore; `RecordRow` and `SuppressionRow` per design §Store generics with `recordID`, `ownerWorkID`, `rows(of:)`, `rows(ids:)`, `make(...)`, `associatedtype ArchiveRecord`, `make(imported:)`, `attach(to:archivedWorkID:)`; `CharacterRecord` and `CharacterSuppression` conform, predicates inside the conformances.   - Renames per the design table: `RecordFact`, `RecordFactIdentity`, `RecordFactCodec`, `RecordNameKey`, `RecordAuthoredContent`, `RecordGroup<Row>` with `CharacterGroup` typealias, `RecordDuplicateSet`, `CitationRepointing`, `RecordRanking`, `WorkRecordPresentation` with `kind`, `WorkRecordFactRow`, `EntryCitingRecord`; files `RecordGroups.swift`, `RecordFacts.swift`, `LibraryRepository+RecordExtraction.swift`, `+RecordEditing.swift`.   - Consumers follow: `ShareWorkContext`, `LibraryProviding`, `MockLibraryProvider`, `EntryDetailModel`, `WorkDetailModel`, `WorkDetailView`, the archive projection and `BackupV11*` (record type names unchanged, Swift type references updated), `CharacterExtractionTypes.swift` in both modules, the app coordinator and review model. `MarkdownExport` and `ShareCharactersRow` untouched.@@ -29,7 +29,7 @@ references:  ## Schema V13 -- [ ] 3. Write failing tests for schema V13, the recorded V12 store, marker generation thirteen and the model contract <!-- id:jsrm212 -->+- [x] 3. Write failing tests for schema V13, the recorded V12 store, marker generation thirteen and the model contract <!-- id:jsrm212 -->   - `ModelContractTests`: V13 declares V12's fifteen entities plus `Place` and `PlaceSuppression`; both CloudKit-legal in the `:230` mould, no relationship, `workID: UUID` defaulted, `kindRaw`/`statusRaw` literal defaults; the one-snapshot-file pin names `AsterismSchemaV12.swift`.   - `V12RecordedStoreFixture` copies `V11RecordedStoreFixture`'s create-seed-save-release ordering and doc comment, seeds no place; `V12RecordedStoreTests`: after conversion both tables empty, nothing else moved, marker `12` → `13`, extension refusal until converted, second open `.ready`.   - `MarkerGenerationThirteenTests` on the Twelve template: `12` lagging, `13` ready, `4`…`11` refused by name, open → validate → publish → clear, a failed publish leaves `12` and the sidecar. Grep the suites for a literal `"13"` used as unrecognised first; `"99"` stays canonical.@@ -39,7 +39,7 @@ references:   - Requirements: [5.2](requirements.md#5.2), [5.6](requirements.md#5.6)   - References: Packages/AsterismCore/Tests/AsterismCoreTests/ModelContractTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreFixture.swift, Packages/AsterismCore/Tests/AsterismCoreTests/V11RecordedStoreTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/MarkerGenerationTwelveTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/FrozenLibraryPathTests.swift, docs/agent-notes/schema-migration.md -- [ ] 4. Freeze V12, declare V13 with Place and PlaceSuppression, retire V11 and move the markers in one commit <!-- id:jsrm213 -->+- [x] 4. Freeze V12, declare V13 with Place and PlaceSuppression, retire V11 and move the markers in one commit <!-- id:jsrm213 -->   - Marker `12` population confirmed 2026-09-10 (`prerequisites.md`), so the plan is `[V12, V13]` and V11 goes in this commit.   - `Models.swift`: `Place` and `PlaceSuppression` per design §Data model inside `extension AsterismSchemaV13`; every typealias repoints; `Place` conforms to `RecordRow` and `PlaceSuppression` to `SuppressionRow` with `#Predicate` on `workID` and `id` inside the conformances; `attach` writes `workID`.   - `AsterismSchemaV12.swift` becomes the frozen snapshot with a header naming the enum raw values its defaults bake in (`CharacterSuppressionKind.candidate`, `CharacterSuppressionStatus.active`); new `AsterismSchemaV13.swift` with seventeen models and `AsterismV13MigrationPlan` = `[V12, V13]`, one lightweight stage.@@ -49,7 +49,7 @@ references:   - Requirements: [5.2](requirements.md#5.2), [5.6](requirements.md#5.6)   - References: Packages/AsterismCore/Sources/AsterismCore/Models.swift, Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV12.swift, Packages/AsterismCore/Sources/AsterismCore/AsterismSchemaV11.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Bootstrap.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+BootstrapState.swift, specs/place-extraction/prerequisites.md -- [ ] 5. Move the remaining schema test sites, seed places in the M5 support and hand-edit the graph baseline <!-- id:jsrm214 -->+- [x] 5. Move the remaining schema test sites, seed places in the M5 support and hand-edit the graph baseline <!-- id:jsrm214 -->   - Every test `Schema(versionedSchema: AsterismSchemaV12` site moves to V13 except the recorded-store fixture (`grep 'Schema(versionedSchema:'` after the freeze); `ModelContractTests` compares V13 against V12; `FrozenLibraryPathTests`' archive-name bucket and `declaresAStoreSchemaOrMarkerGeneration` name V12, V13 and `AsterismV13MigrationPlan`.   - `M5RepositoryTestSupport.seedM5Rows` gains `places:` and `placeSuppressions:` on the `M5SeedCharacter`/`M5SeedSuppression` shapes with `workID` instead of a row index; readers `m5PlaceRows(id:)`, `m5PlaceSuppressionRows()`.   - `LibraryGraphBaselineTests` fetches both tables; `library-graph-baseline.txt` moves one format with two counts and two sections, edited by hand and diff-reviewed.@@ -61,7 +61,7 @@ references:  ## Repository over both kinds -- [ ] 6. Write failing tests for place convergence, collapse repointing, work deletion, merge and orphan tolerance <!-- id:jsrm215 -->+- [x] 6. Write failing tests for place convergence, collapse repointing, work deletion, merge and orphan tolerance <!-- id:jsrm215 -->   - `RecordGroupTests` over `RecordGroup<Place>`: torn and converged groups, `rows(of:)` hides an orphan and `rows(ids:)` reaches it, delete-by-fetch then `rollback()` survives, a group whose rows disagree on `workID` is not torn and appears under each work.   - `DuplicateScan` forms `placeSets` by UUID only and `.merge` is unreachable; `DuplicateReconciler` converges place groups and retains their keys; `DuplicateWorkload` routes place sets `.sheet`; `resolveRecordSet` chosen-only; entry collapse repoints place citations and suppressions in the same transaction as characters, fanned to every row.   - Work deletion removes places and place suppressions by predicate in the same commit; a refused deletion rolls back cleanly. Merge rewrites `workID` on every place and suppression row, re-encodes only groups with generic-notes citations, unions place suppressions, resets coverage, reports `movedPlaceCount`; `WorkMergeContract` carries the count; `BackupGroupProjection` includes torn place groups.@@ -71,7 +71,7 @@ references:   - Requirements: [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5)   - References: Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift, Packages/AsterismCore/Tests/AsterismCoreTests/DuplicateResolutionTests.swift -- [ ] 7. Implement the place arms of the duplicate scan, reconciler, resolution, workload, redirect, deletion, merge and group projection <!-- id:jsrm216 -->+- [x] 7. Implement the place arms of the duplicate scan, reconciler, resolution, workload, redirect, deletion, merge and group projection <!-- id:jsrm216 -->   - Design §Convergence parity table, every row: `DuplicateRecordType.place` appended last; `recordSets<Row>(of:)` yields `characterSets` and `placeSets`; `.place` arms in resolve, contract, choice, differing-fields, reconciler retain and converge, workload, redirect; `CitationRepointing.repoint` over both kinds at both collapse sites.   - Work deletion: `Place.rows(of:)` and `PlaceSuppression.rows(of:)` deleted beside the character walk. `movePlaces` beside `moveCharacters` at the merge call site and in the basis/report.   - `DuplicateResolutionContract.place(...)` carries `[RecordVariantChoice]` shared with the character arm (`AuthoredVariant<RecordAuthoredContent>`).@@ -80,7 +80,7 @@ references:   - Requirements: [3.5](requirements.md#3.5), [3.6](requirements.md#3.6), [5.3](requirements.md#5.3), [5.5](requirements.md#5.5)   - References: Packages/AsterismCore/Sources/AsterismCore/DuplicateScan.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateReconciler.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateWorkload.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+DuplicateResolution.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+Redirect.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDeletion.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkMerge.swift, Packages/AsterismCore/Sources/AsterismCore/BackupGroupProjection.swift, Packages/AsterismCore/Sources/AsterismCore/DuplicateResolution.swift -- [ ] 8. Write failing tests for the kind-aware candidate read, decision commit and coverage <!-- id:jsrm217 -->+- [x] 8. Write failing tests for the kind-aware candidate read, decision commit and coverage <!-- id:jsrm217 -->   - `CharacterExtractionRepositoryTests` parameterised over `RecordKind` plus new arms: `extractionCandidates` returns per-kind `records`, `acceptedFacts` and suppression index with one place fetch for all examined works; a place skip leaves the character key untouched and vice versa; a dual row skip (`returnedKinds` both, no target) suppresses under both kinds; accept under `.place` clears only place suppressions; `.reRouted` evaluated under `request.kind`; a torn place target refuses acceptance; coverage advances once per source whichever kind decided it.   - Suppression convergence (Q82 ordering) over `PlaceSuppression` rows identical to character rows.   - Blocked-by: jsrm216 (Implement the place arms of the duplicate scan, reconciler, resolution, workload, redirect, deletion, merge and group projection)@@ -88,8 +88,9 @@ references:   - Requirements: [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [5.4](requirements.md#5.4)   - References: Packages/AsterismCore/Tests/AsterismCoreTests/CharacterExtractionRepositoryTests.swift, Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift -- [ ] 9. Implement extractionCandidates, commitDecision and advanceCoverage over both kinds <!-- id:jsrm218 -->+- [x] 9. Implement extractionCandidates, commitDecision and advanceCoverage over both kinds <!-- id:jsrm218 -->   - `ExtractionCandidate` with per-kind dictionaries; `DecisionRequest` gains `kind`, `returnedKinds`; `commitDecision(_:)` switches on `kind` into one generic body over `Row: RecordRow, Suppression: SuppressionRow`; `advanceCoverage` renamed, unchanged.+  - The candidate read must use `CharacterExtractionContext`'s per-kind initialiser, never the character-only convenience one, which compiles and yields an empty place half; delete the convenience initialiser once no character test needs it (task 17 review).   - The candidate read: characters through the group rows as today, places through one `Place.rows(of:)` over every examined work grouped in memory; torn works still excluded.   - `writeSuppression` becomes generic; the edit path's `inout` twin delegates as today.   - Blocked-by: jsrm217 (Write failing tests for the kind-aware candidate read, decision commit and coverage)@@ -97,7 +98,7 @@ references:   - Requirements: [1.4](requirements.md#1.4), [1.6](requirements.md#1.6), [2.3](requirements.md#2.3), [2.4](requirements.md#2.4), [2.5](requirements.md#2.5), [5.4](requirements.md#5.4)   - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterExtraction.swift, Packages/AsterismCore/Sources/AsterismCore/CharacterExtractionTypes.swift -- [ ] 10. Write failing tests for commitRecordEdits over both kinds including conversion <!-- id:jsrm219 -->+- [x] 10. Write failing tests for commitRecordEdits over both kinds including conversion <!-- id:jsrm219 -->   - `CharacterEditingTests` over kind, plus: `.convert(basis:to:draft:)` creates a row of the other kind with a new UUID carrying the draft's name, aliases, note and facts and the original's retained key; fact-triple suppressions written under the old kind keyed to the retained key and no name-key suppression; the new kind's suppressions cleared for the basis's retained, current and alias keys, the draft's name and alias keys and the carried triples; torn source refuses `.torn`; basis mismatch rolls the whole step back; `.kindMismatch` for a combine across kinds and an update against a basis of the other kind.   - Hand-created place commit clears a standing place suppression of its key; place combine mirrors the character combine tests including alias union, fact re-key and suppression re-key.   - Mixed-kind operation lists in one call apply in performed order and save once.@@ -106,7 +107,7 @@ references:   - 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.7](requirements.md#3.7)   - References: Packages/AsterismCore/Tests/AsterismCoreTests/CharacterEditingTests.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift -- [ ] 11. Implement commitRecordEdits with the convert operation <!-- id:jsrm21a -->+- [x] 11. Implement commitRecordEdits with the convert operation <!-- id:jsrm21a -->   - `RecordEditBasis/Draft/Operation/Refusal/Outcome` with `kind`; `commitRecordEdits(workID:operations:)` replaces `commitCharacterEdits`; `.convert` is the one operation touching two tables and runs inside the same save; `LibraryProviding` and `MockLibraryProvider` follow.   - Generic bodies for create, update, delete, combine over `Row`; `verifyOnFirstTouch` keyed by `(kind, id)`.   - Blocked-by: jsrm219 (Write failing tests for commitRecordEdits over both kinds including conversion)@@ -114,32 +115,33 @@ references:   - 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.7](requirements.md#3.7)   - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+CharacterEditing.swift, Asterism/Asterism/Support/LibraryProviding.swift, Asterism/AsterismTests/Helpers/MockLibraryProvider.swift -- [ ] 12. Write failing tests for the work detail and entry detail place reads and the place ranking arm <!-- id:jsrm21b -->+- [x] 12. Write failing tests for the work detail and entry detail place reads and the place ranking arm <!-- id:jsrm21b -->   - `WorkDetailReadTests`: `WorkDetailPresentation.places` ranked by `RecordRanking` over places alone, name order for no-fact places, one place fetch per read, torn place `isTorn`; `EntryDetailTests`: `citingPlaces` in name order populated in the single locked read, separate from `citingCharacters`.-  - `M4ScalePerformanceTests.placeRankingAtScale`: 200 places × 50 facts over 500 entries under `characterRankingBudget`/ceiling, fixture cloned from `CharacterRankingFixture` over `Place`; `M4DuplicateScalePerformanceTests`' reconcile fixture seeds places at the character density so the repoint cost is measured.+  - `M4ScalePerformanceTests.placeRankingAtScale`: 200 places × 50 facts over 500 entries under `characterRankingBudget`/ceiling, fixture cloned from `CharacterRankingFixture` over `Place`; `M4DuplicateScalePerformanceTests`' reconcile fixture is left unchanged: it seeds no characters, so the character density of places is zero (Q74).   - Blocked-by: jsrm21a (Implement commitRecordEdits with the convert operation)   - Stream: 1   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [6.2](requirements.md#6.2)   - References: Packages/AsterismCore/Tests/AsterismCoreTests/WorkDetailReadTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/M4ScalePerformanceTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/M4DuplicateScalePerformanceTests.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift -- [ ] 13. Implement the place reads, RecordRanking over places and the M4 placeRankingAtScale arm <!-- id:jsrm21c -->+- [x] 13. Implement the place reads, RecordRanking over places and the M4 placeRankingAtScale arm <!-- id:jsrm21c -->   - `LibraryRepository+WorkDetail.swift`: `places` presentations beside `characters` from one `Place.rows(of:)` fetch; `LibraryRepository+EntryDetail.swift`: `citingPlaces` on `EntryTeachingDetail`; `RecordRanking.rankGroups<Row>` shared; the M4 arm and the reconcile fixture change from task 12; Makefile `--filter` alternation unchanged unless a new suite is added.   - Blocked-by: jsrm21b (Write failing tests for the work detail and entry detail place reads and the place ranking arm)   - Stream: 1   - Requirements: [4.1](requirements.md#4.1), [4.2](requirements.md#4.2), [4.3](requirements.md#4.3), [4.4](requirements.md#4.4), [6.2](requirements.md#6.2)-  - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift, Packages/AsterismCore/Sources/AsterismCore/WorkCharacterPresentation.swift, Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift+  - References: Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+WorkDetail.swift, Packages/AsterismCore/Sources/AsterismCore/LibraryRepository+EntryDetail.swift, Packages/AsterismCore/Sources/AsterismCore/WorkRecordPresentation.swift, Packages/AsterismCore/Sources/AsterismCore/M4PerformanceFixture.swift  ## Archive 12/13 -- [ ] 14. Write failing tests for the 12/13 archive <!-- id:jsrm21d -->+- [x] 14. Write failing tests for the 12/13 archive <!-- id:jsrm21d -->   - `BackupV12ArchiveTests` fresh from `BackupV11ArchiveTests`: round trip into a seeds-only library with places and place suppressions, repeated import a no-op, `modifiedAt`/`actionAt` guards, a place whose `workID` resolves nothing kept on import and exported as-is, a torn place group refuses export, an 11/12 archive refused by name, pre-feature-shaped payload without the two arrays refused as any 11/12 is.   - `BackupGoldenExportTests` gains one non-empty-array `#expect` per new array and the orphan-place shape assertion; `BackupV12Fixtures` from `BackupV11Fixtures`.+  - Pin the fourth thing: a test tying `BackupV12Document.schemaVersion` to `AsterismSchemaV13.versionIdentifier.major` (and the format to schema minus one), so a future bump that forgets the archive fails `make test-core` instead of shipping a mislabelled archive (phase 2 review).   - Blocked-by: jsrm21c (Implement the place reads, RecordRanking over places and the M4 placeRankingAtScale arm)   - Stream: 1   - Requirements: [5.1](requirements.md#5.1), [5.3](requirements.md#5.3)   - References: Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11ArchiveTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupGoldenExportTests.swift, Packages/AsterismCore/Tests/AsterismCoreTests/BackupV11Fixtures.swift -- [ ] 15. Rename the archive to BackupV12, add the place records and the generic import, and re-record the golden <!-- id:jsrm21e -->+- [x] 15. Rename the archive to BackupV12, add the place records and the generic import, and re-record the golden <!-- id:jsrm21e -->   - `BackupV11Types/Codec/Exporter.swift` → `BackupV12*`, every record renamed, `formatVersion = 12`, `schemaVersion = 13`, old codec deleted; `BackupV12Place` and `BackupV12PlaceSuppression` with non-optional `workID`; `BackupV12Payload` and `BackupImportPayload` gain the two arrays; exporter enumerates both tables whole.   - `mergeImportedRecords<Row>` and `mergeImportedSuppressions<Row>` generalised from `BackupImportCharacters.swift`, called once per kind; `ArchiveRecordBuilders` per record; `BackupImporter.supportedVersions` follows the constants; the capability gate literal stays.   - Delete `backup-11-12-golden.json`; `BackupGoldenLibrary` seeds a place, a place suppression with `sourceEntryID` and a place with an absent work; record `backup-12-13-golden.json` with `ASTERISM_RECORD_GOLDEN=1`, then re-run without it.@@ -150,7 +152,7 @@ references:  ## Pipeline -- [ ] 16. Write failing tests for place grounding, the assembler dual-kind arms, the ledger proposal key and the combined client <!-- id:jsrm21f -->+- [x] 16. Write failing tests for place grounding, the assembler dual-kind arms, the ledger proposal key and the combined client <!-- id:jsrm21f -->   - `CharacterGroundingTests`: the `places` array under the same rules with `kind` tagged; the five synthetic probes from `prototype/Sources/main.swift` as fixtures (sentence-initial "Hotel" kept, lowercase "school" dropped, uncased script exempt); slash split on a place name.   - `CharacterExtractionAssemblerTests`: per-source-response per-kind matching and filtering with the Q36 survival predicate; the three dual-kind arms (both unmatched → one character row with the union re-deduped under character and `returnedKinds` both; one matched → bundle plus candidate; both matched → two bundles); a filtered copy leaves a single-kind candidate; "Bay" from source A as character and source B as place stays two rows; cross-source grouping unions `returnedKinds`; per-kind caps with `maximumPlaceCandidates`.   - `CharacterExtractionLedgerTests`: `ProposalKey` identity across kinds, `reclassify` sets `displayedKind` and target and survives `merged`, `merged` never inherits a target under the assembled kind, `reconcile` over `recordIDs` per kind.@@ -160,7 +162,7 @@ references:   - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8)   - References: Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterGroundingTests.swift, Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionAssemblerTests.swift, Packages/AsterismCore/Tests/AsterismIntelligenceTests/CharacterExtractionLedgerTests.swift, Packages/AsterismCore/Tests/AsterismIntelligenceTests/FoundationCharacterExtractionModelClientTests.swift, specs/place-extraction/prototype/Sources/main.swift -- [ ] 17. Implement the combined result and instructions, grounding by kind, the assembler steps, the ledger proposal key and reclassify <!-- id:jsrm21g -->+- [x] 17. Implement the combined result and instructions, grounding by kind, the assembler steps, the ledger proposal key and reclassify <!-- id:jsrm21g -->   - `ExtractionResult.places`, `ExtractedPlace`, `ExtractedPlaceFact` with the prototype's `@Guide` strings; `instructions` and `prompt` verbatim from `instructionsB`/`promptB` in `prototype/Sources/main.swift`; `CharacterExtractionModelClient`, stub and recorder untouched.   - `CharacterGrounding.ground` over both arrays, `GroundedCandidate.kind`; `CharacterExtractionBounds.maximumPlaceCandidates = 12`; overflow logged by kind.   - `CharacterExtractionAssembler` steps 1–4 per design §Pipeline; `ExtractionProposal.kind`, `displayedKind`, `returnedKinds`, `Target.newRecord`; `ProposalKey`; ledger `held` keyed by `ProposalKey`, `hold`/`merged`/`discard`/`retarget`/`reclassify`, `WorkExtractionState.recordIDs`.@@ -169,8 +171,9 @@ references:   - Requirements: [1.1](requirements.md#1.1), [1.2](requirements.md#1.2), [1.3](requirements.md#1.3), [1.5](requirements.md#1.5), [1.6](requirements.md#1.6), [1.7](requirements.md#1.7), [1.8](requirements.md#1.8)   - References: Packages/AsterismCore/Sources/AsterismIntelligence/ExtractionResult.swift, Packages/AsterismCore/Sources/AsterismIntelligence/FoundationCharacterExtractionModelClient.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterGrounding.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionAssembler.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionLedger.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionTypes.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBounds.swift -- [ ] 18. Write failing tests for the bridge decision request and the coordinator contexts, reclassify and cross-kind discard <!-- id:jsrm21h -->+- [x] 18. Write failing tests for the bridge decision request and the coordinator contexts, reclassify and cross-kind discard <!-- id:jsrm21h -->   - `CharacterExtractionCoordinatorTests`: `contexts[work]` retained after a pass and refreshed by `reconcile()`; `reclassify(key:for:to:target:)` reaches the ledger; a dual skip decision discards every held row of that name key across kinds; `discard`/`retarget` by `ProposalKey`; a request still carries one source's text and title only; `producedNone` when both arrays ground to nothing.+  - Pin Q68: a test that an unmatched copy whose every fact deduped away, and which the model did not report name-only, is not shown, and that its name therefore cannot join a dual-kind union. Fix `returnedKinds`' doc comment: a filtered-out copy is excluded (Q30, Q33).   - Bridge: `decisionRequest` emits `kind = displayedKind`, `returnedKinds`, `displayedTargetID`, from a projected proposal; `completedSources` unchanged.   - `UITestLaunchSupport.cannedExtractionResult` gains an `ExtractedPlace` whose quote sits verbatim and capitalised in the seeded fixture's note, plus a name returned under both kinds.   - Blocked-by: jsrm21g (Implement the combined result and instructions, grounding by kind, the assembler steps, the ledger proposal key and reclassify), jsrm218 (Implement extractionCandidates, commitDecision and advanceCoverage over both kinds)@@ -178,8 +181,9 @@ references:   - Requirements: [1.4](requirements.md#1.4), [2.2](requirements.md#2.2), [2.4](requirements.md#2.4)   - References: Asterism/AsterismTests/CharacterExtractionCoordinatorTests.swift, Packages/AsterismCore/Sources/AsterismIntelligence/CharacterExtractionBridge.swift, Asterism/Asterism/UITestLaunchSupport.swift -- [ ] 19. Implement the bridge and coordinator changes and the canned UI-test result with a place <!-- id:jsrm21i -->+- [x] 19. Implement the bridge and coordinator changes and the canned UI-test result with a place <!-- id:jsrm21i -->   - `CharacterExtractionCoordinator`: `contexts: [UUID: ExtractionContext]`, `reclassify`, `discard` keyed by `ProposalKey` with the cross-kind sweep on `returnedKinds`, `retarget` by key; `CharacterExtractor` grounds both arrays; log lines name the kind under the unchanged category.+  - Task 17 already landed the extractor's kind-named drop line and the coordinator's `recordIDs`; do not redo them. Until this task lands, a `Development` install shows place rows as unlabelled character rows and `discard(nameKey:)` cannot remove them; that is the expected mid-stream state, not a bug.   - `CharacterExtractionBridge` per task 18; `AppLibraryModel.seedCharactersFixture` note text gains a capitalised place; `UITestLaunchSupport` canned result.   - Blocked-by: jsrm21h (Write failing tests for the bridge decision request and the coordinator contexts, reclassify and cross-kind discard)   - Stream: 2@@ -188,22 +192,24 @@ references:  ## Review and edit surfaces -- [ ] 20. Write failing tests for the review model reclassify preview, projected proposal, cross-kind hint and dual-kind disclosure <!-- id:jsrm21j -->+- [x] 20. Write failing tests for the review model reclassify preview, projected proposal, cross-kind hint and dual-kind disclosure <!-- id:jsrm21j -->   - `CharacterReviewModelTests`: rows carry `kind`, `originalKind`, `canReclassify` (candidates and reclassification-produced bundles only), `isDualKind`, `crossKindHint`; `reclassify` redraws candidate ↔ bundle from the presentations by the three tiers, re-keys and re-dedups facts under the destination against presentations and the coordinator's suppression index, keeps ticks and strikes by index, never removes the row, disables accept when no facts remain; `decide` builds the request from the projection; the hint text for an accepted character's name on a place row and the reverse; the dual-kind disclosure; a refused `.reRouted` after reclassify refreshes as a bundle.+  - A merged row re-runs the reclassify preview under the reader's `displayedKind` over the merged content and replaces the target the ledger kept (design §Pipeline, Q69): assert the re-preview after a `merged` ledger entry.   - Blocked-by: jsrm21i (Implement the bridge and coordinator changes and the canned UI-test result with a place), jsrm21c (Implement the place reads, RecordRanking over places and the M4 placeRankingAtScale arm)   - Stream: 3   - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3)   - References: Asterism/AsterismTests/CharacterReviewModelTests.swift, Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift -- [ ] 21. Implement the review model and review view changes <!-- id:jsrm21k -->+- [x] 21. Implement the review model and review view changes <!-- id:jsrm21k -->   - `CharacterReviewModel`: `reclassify(_:to:)`, projected proposal on the row, `onReclassify` closure to the coordinator, per-row hint and dual flags; row id from `ProposalKey`.+  - The merged-row re-preview per task 20 and Q69: the ledger keeps the older row's target; the review model re-previews it under the merged content.   - `CharacterReviewView`: title "Suggested characters and places", kind caption in each section header, segmented `Picker` ("Character", "Place") above Keep/Skip on reclassifiable rows with identifier `character-review-kind-<rowid>`, "Suggested as both a character and a place" line, hint under the bundle note; existing identifiers keep their prefixes with the new row id.   - Blocked-by: jsrm21j (Write failing tests for the review model reclassify preview, projected proposal, cross-kind hint and dual-kind disclosure)   - Stream: 3   - Requirements: [2.1](requirements.md#2.1), [2.2](requirements.md#2.2), [2.3](requirements.md#2.3)   - References: Asterism/Asterism/CharacterExtraction/CharacterReviewModel.swift, Asterism/Asterism/Views/CharacterReviewView.swift, Asterism/Asterism/Views/WorkDetailView.swift -- [ ] 22. Write failing tests for the record edit session, conversion staging, the commit order and entry detail places <!-- id:jsrm21l -->+- [x] 22. Write failing tests for the record edit session, conversion staging, the commit order and entry detail places <!-- id:jsrm21l -->   - `WorkDetailCharacterTests` over kind plus: `recordDrafts` keyed by UUID with `kind`, `characterDrafts`/`placeDrafts` filtered views, `addPlace`, `convertRecord(id:)` flips the draft's kind and stages `.convert`, convert-then-edit emits one `.convert(basis:to:draft:)` and no `.update`, convert-then-delete a plain delete, create-then-convert a create under the other kind, `combineTargets` excludes converted and created records, un-convert removes the staged op; `commitEditing` order URL → metadata (no reload when a record step follows) → one `commitRecordStep` → `onMutation()` → `load()`; a thrown metadata save keeps the staged record session.   - `EntryDetailModel.citingPlaces` from the teaching detail with no second read.   - Blocked-by: jsrm21a (Implement commitRecordEdits with the convert operation), jsrm21c (Implement the place reads, RecordRanking over places and the M4 placeRankingAtScale arm)@@ -211,7 +217,7 @@ references:   - Requirements: [3.2](requirements.md#3.2), [3.7](requirements.md#3.7), [4.4](requirements.md#4.4)   - References: Asterism/AsterismTests/WorkDetailCharacterTests.swift, Asterism/Asterism/ViewModels/WorkDetailModel.swift, Asterism/Asterism/ViewModels/EntryDetailModel.swift -- [ ] 23. Implement the record edit session, the editor kind parameter and convert action, and the work page and entry detail place surfaces <!-- id:jsrm21m -->+- [x] 23. Implement the record edit session, the editor kind parameter and convert action, and the work page and entry detail place surfaces <!-- id:jsrm21m -->   - `WorkDetailModel` record session per task 22; `CharacterEditorView(kind:)` with the `RecordKind` copy table, "Make this a place"/"Make this a character" footer between Combine and Delete, hidden for torn and session-created records, "Make this a character again" for a converted one.   - `WorkDetailView`: `placesSection` after `charactersSection` under `ConstellationSectionHeader("Places", accent: .violet)` with shared pill, detail card and fact-row helpers parameterised by kind and identifiers `work-detail-place*`; `editPlacesSection` captioned card after Characters with `ConstellationFooterButton("Add a place")` and `work-detail-place-line-<uuid>`; indicator counts both kinds ("N suggestions from your notes"); `manualPassRow` label "Look for characters and places"; torn section names both kinds; `expandedPlaceID`/`expandedEditPlaceID` state.   - `EntryDetailView.citingPlacesSection` after the characters one with header "Places" and `entry-detail-citing-place`; `.place` arms in `RecentView` filters, `MaintenanceViewModels` noun, `DuplicateResolutionModel.placeVariants` and the resolution sheet; `AppLibraryModel` conflict routing passes `.place`.@@ -220,7 +226,7 @@ references:   - Requirements: [2.1](requirements.md#2.1), [3.2](requirements.md#3.2), [3.7](requirements.md#3.7), [4.1](requirements.md#4.1), [4.4](requirements.md#4.4)   - References: Asterism/Asterism/ViewModels/WorkDetailModel.swift, Asterism/Asterism/Views/CharacterEditorView.swift, Asterism/Asterism/Views/WorkDetailView.swift, Asterism/Asterism/Views/EntryDetailView.swift, Asterism/Asterism/Views/RecentView.swift, Asterism/Asterism/ViewModels/MaintenanceViewModels.swift, Asterism/Asterism/ViewModels/DuplicateResolutionModel.swift, Asterism/Asterism/ViewModels/AppLibraryModel.swift -- [ ] 24. Write the UI tests and update the seeded characters fixture <!-- id:jsrm21n -->+- [x] 24. Write the UI tests and update the seeded characters fixture <!-- id:jsrm21n -->   - `CharacterExtractionUITests` over the extended canned result: indicator counts both kinds; a place row labelled and kept into the Places section; a character row reclassified to place then kept; the hint on an accepted character's name; the dual-kind line; convert in the editor moves the line between cards and survives Save; entry detail Places section; manual-pass label; existing tests updated for the new row ids.   - `AccessibilityJourneyUITests` and `WideLayoutUITests` gain the Places section and the picker at the largest text size; both run through `make test-ui` and `make test-ui-ipad`.   - Blocked-by: jsrm21m (Implement the record edit session, the editor kind parameter and convert action, and the work page and entry detail place surfaces), jsrm21e (Rename the archive to BackupV12, add the place records and the generic import, and re-record the golden)@@ -230,9 +236,9 @@ references:  ## Verification and documents -- [ ] 25. Run the host performance suite and record the verification run <!-- id:jsrm21o -->-  - `make test-performance-m4` on this branch, host only, about 21 minutes plus the release build; exit 0 expected with the known-issue set CLAUDE.md records plus whatever `placeRankingAtScale` and the place-seeded reconcile arm report.-  - Create `specs/place-extraction/verification-run.md` on the `work-creators` file's shape with every band and known issue from this run; the reconcile arm's number is Risk 1's verification. Never append results to `tasks.md`.+- [x] 25. Run the host performance suite and record the verification run <!-- id:jsrm21o -->+  - `make test-performance-m4` on this branch, host only, about 21 minutes plus the release build; exit 0 expected with the known-issue set CLAUDE.md records plus whatever `placeRankingAtScale` reports (label `place-ranking-200x50`; there is no place-seeded reconcile arm, Q74).+  - Create `specs/place-extraction/verification-run.md` on the `work-creators` file's shape with every band and known issue from this run; `placeRankingAtScale`'s number is Risk 1's verification (Q74). Never append results to `tasks.md`.   - Blocked-by: jsrm21n (Write the UI tests and update the seeded characters fixture)   - Stream: 1   - Requirements: [6.2](requirements.md#6.2)@@ -248,9 +254,10 @@ references:   - References: specs/place-extraction/prerequisites.md, CLAUDE.md, Asterism/Asterism/CharacterExtraction/CharacterExtractionLog.swift, specs/character-extraction/decision_log.md  - [ ] 27. Update the agent notes, style and design docs, overview, changelog and CLAUDE.md for V13 <!-- id:jsrm21q -->-  - `docs/agent-notes/schema-migration.md` (state at V13, marker `"13"`, History, `AsterismSchemaV12` as the snapshot, the generic-store note and the `recordID` rule); the archive-name bucket in `FrozenLibraryPathTests`; `docs/asterism-design.md` (Places section, eight edit sections, four collections, conversion) and `docs/asterism-style-guide.md` (§7 "four collections", §10 "five compact line kinds", the review picker); `specs/OVERVIEW.md` row and section; `CHANGELOG.md`.-  - `CLAUDE.md`: the schema sentences to V13 and marker `"13"`, the `make test-performance-m4` paragraph with the new arms and counts from `verification-run.md`, and the Data model sentence claiming every table since V6 has no relationship corrected to name `Character` and `CharacterSuppression` as the V7 exceptions.-  - `docs/agent-notes/testing.md` known-issue enumeration and suite/test counts.+  - `docs/agent-notes/schema-migration.md` (state at V13, marker `"13"`, History, `AsterismSchemaV12` as the snapshot, the generic-store note and the `recordID` rule, and its archive section moved from 11/12 to 12/13 naming `BackupV12*`, `supportedVersions == (12, 13)` and `backup-12-13-golden.json` as the re-recorded golden; the `FrozenLibraryPathTests` archive-name bucket is already done by task 15); `docs/asterism-design.md` (Places section, eight edit sections, four collections, conversion) and `docs/asterism-style-guide.md` (§7 "four collections", §10 "five compact line kinds", the review picker); `specs/OVERVIEW.md` row and section; `CHANGELOG.md`.+  - `CLAUDE.md`: the schema sentences to V13, marker `"13"` and the archive generation 12/13, the `make test-performance-m4` paragraph with the new arms and counts from `verification-run.md`, and the Data model sentence claiming every table since V6 has no relationship corrected to name `Character` and `CharacterSuppression` as the V7 exceptions.+  - `docs/agent-notes/testing.md` known-issue enumeration and suite/test counts: `AsterismTests` gains `WorkDetailRecordSessionTests`, `CharacterExtractionUITests` 9 → 14 cases, `AccessibilityJourneyUITests` 13 → 14, `WideLayoutUITests` 21 → 22, `make test-ui` 149 tests with 2 skipped, `make test-ui-ipad` 24; note that the review list scrolls so a review-row assertion walks it, and that every element of a SwiftUI `Label` carries the identifier.+  - Style guide counts (§7 four collection cards, §10 five compact line kinds), the design doc's Places section, the editor's third structural action and its take-back label, `RecordKindPresentation` as the one copy table, the indicator "N suggestions from your notes" and manual pass "Look for characters and places", and a note that the relabelled manual-pass row is a designed exception to Req 4.1's "no place-related element" on a place-less work. Identifiers on shared surfaces keep their `character` names (`work-detail-character-proposals`, `work-detail-character-convert` and friends) by design.   - Blocked-by: jsrm21o (Run the host performance suite and record the verification run), jsrm21p (Publish the record types to the dev container and capture the phone timing logs for the 6.1 gate)   - Stream: 1   - Requirements: [5.6](requirements.md#5.6)
specs/place-extraction/verification-run.md Added +212 / -0
diff --git a/specs/place-extraction/verification-run.md b/specs/place-extraction/verification-run.mdnew file mode 100644index 0000000..b0b70fd--- /dev/null+++ b/specs/place-extraction/verification-run.md@@ -0,0 +1,212 @@+# Verification Run: Place Extraction++The evidence for task 25, recorded here rather than in `tasks.md`, which `rune`+owns.++**Date**: 2026-09-11+**Host**: the project machine, macOS 26, Apple Silicon, **quiet** — nothing else+was building, testing or searching this checkout while the run was in flight.+**Host only.** No device target was run and none may be:+`make test-performance-m4-recent`, `make install`, `make run` and `xcrun+devicectl` all touch the owner's phone, and the Mac app is a device target too —+nothing here opened, launched or installed it (`CLAUDE.md`).++**One run is not a baseline.** `CLAUDE.md` says so and the suites' history says+it louder: three consecutive release runs of unchanged code once measured+0.7805 s, 1.2789 s and 0.7389 s. The run below was made **once**, as task 25+asks. Read a delta of a few percent as noise, not as a movement.++---++## 1. The run++| | |+|---|---|+| Command | `make test-performance-m4` (with `PERFORMANCE_LOG` set, which changes nothing about what is measured) |+| Where | this worktree, `T-2276/place-extraction`, at `4f2113c` |+| Outcome | **exit 0** — **41 tests in 7 suites passed** with **9 known issues** and no failure |+| Wall time | **1,070.5 s of test time** (17 m 50 s), plus a **375.7 s** release build — 1,450 s (24 m 10 s) end to end |++41, not 40: this feature adds exactly one arm, `place-ranking-200x50`+([§2](#2-risk-1-the-place-ranking-arm-req-62)). The suite count is unchanged at+7 — Q74 left `M4DuplicateScalePerformanceTests`' reconcile fixture alone rather+than seeding places into it, so no new suite and no new fixture shape entered+the 21-minute target.++Nine known issues is the steady state `docs/agent-notes/testing.md` records since+`work-creators`, unchanged: this feature adds none and retires none.+`creator-converge-noop` — the intermittent tenth — did **not** fire, which is+what a quiet host looks like.++## 2. Risk 1: the place ranking arm (Req 6.2)++Req [6.2](requirements.md#6.2) asks that the `character-ranking` Req 4.3 ranking+function, invoked over places, meets that criterion's **10 ms budget and 50 ms+ceiling**. Q74 makes this arm Risk 1's whole performance verification: there is+no place-seeded reconcile arm, because the duplicate fixture seeds zero character+rows, so "places at the character density" there is zero.++| Arm | Median | Spread | 10 ms budget | 50 ms ceiling |+|---|---|---|---|---|+| **`place-ranking-200x50`** | **0.003547 s** | 1.01× (n=20) | **in budget**, at **35%** of it | **in ceiling**, at 7.1% of it |+| `character-ranking-200x50` | 0.003681 s | 1.06× (n=20) | in budget, at 37% of it | in ceiling, at 7.4% of it |++Both are recorded because the arm exists to be read beside its sibling: the+ranker is one generic implementation over `RecordRow`, and what+`placeRankingAtScale` measures is that the **second conformance costs what the+first does**. It does — 0.003547 s against 0.003681 s, the place arm **3.6%+under** the character arm, which is inside one run's noise on this host. There is+no per-kind cost.++### 2.1 The character arm moved, and it is the generic refactor++`character-ranking-200x50` measured **0.003681 s** here against **0.002461 s** on+the `work-creators` branch run and 0.002349 s on that file's baseline+(`../work-creators/verification-run.md` §1.1 and §3) — **+50%**.++That is worth saying plainly rather than filing under noise, because every other+arm in this run went the *other* way by 3–10% ([§4](#4-every-measured-label)):+the host is quieter today, so a 50% rise against it is a real cost, not variance.+It is the cost of `CharacterRanking` becoming `RecordRanking` — one generic+`order` over `RecordRow` in place of a concrete one — and 1.2 ms is what that+costs over 200 records × 50 facts.++It is **not** a breach and it is not close to one: 37% of the 10 ms budget, 7.4%+of the 50 ms regression ceiling asserted outside the block. Both bounds hold with+room to spare, and no bound was adjusted. Recorded here so a future run reads+0.0037 s as the new resting place rather than as a fresh regression.++## 3. The nine known issues++Every one has its regression ceiling asserted **outside** the known-issue block,+so a run that drifts further still fails the target.++| # | Known issue | This run | Bound |+|---|---|---|---|+| 1 | The full-tier no-op reconcile (Req 1.7) | 0.030192 s | 10 ms ceiling — breached 3.02×, inside the recorded band |+| 2 | `series-and-related-works` Req 14.6's link dedupe | 0.010032 s | 10 ms budget — breached 1.003×, 25 ms ceiling not reached |+| 3 | `work-creators` Req 11.6's credit dedupe | 0.059056 s | 50 ms budget — breached 1.18×, 130 ms ceiling not reached |+| 4–6 | Req 5.4's three capture-projection arms | 0.164295 s / 0.158088 s / 0.163976 s | 100 ms budget, 250 ms ceiling not reached |+| 7–9 | Req 5.5's three diagnosis re-derivations | 0.264249 s / 0.264120 s / 0.265298 s | 250 ms budget, 400 ms ceiling not reached |++Two readings worth recording:++- **`dedupe-links-noop` is 32 µs over its 10 ms budget.** It has been an accepted+  breach since `series-and-related-works` (that spec's Q59) and it stays one, but+  this is the closest it has come to fitting — 0.010032 s here against+  0.010991 s on the `work-creators` branch run and 0.011109 s on that baseline.+  The known issue is not `isIntermittent`, so a quiet host that lands under 10 ms+  would turn it into a *second* way to be red. It did not happen here; note it as+  a thing to watch rather than a thing to change.+- **`creator-converge-noop` did not fire**, at **0.008071 s** against its 10 ms+  budget — 81% of it, where the three samples behind `work-creators` Q74 sat at+  94%, 98% and 99.7%. `isIntermittent: true` is why a quiet run records nothing+  and the count stays nine.++## 4. Every measured label++Medians, against the `work-creators` branch run+([`../work-creators/verification-run.md`](../work-creators/verification-run.md)+§3, 2026-09-08), which is the last full recording. The comparison is not+like-for-like on host state — that run reported visibly more variance than this+one — so read the near-uniform negative deltas as a quieter machine rather than+as this feature making the library faster.++| Measurement | This run | `work-creators` §3 | Δ | Bound | Verdict |+|---|---|---|---|---|---|+| `open-coherent` | 0.685845 s | 0.7325 s | −6.4% | 1 s budget | in budget |+| `open-duplicateSiteRows` | 0.679967 s | 0.7383 s | −7.9% | 1 s budget, ≤ 1.25× ratio | in budget, ratio 0.99× |+| `open-siteMissing` | 0.316202 s | 0.3430 s | −7.8% | 1 s budget | in budget |+| `open-duplicateIdentity` | 0.680553 s | 0.7255 s | −6.2% | 1 s budget | in budget |+| `extension-open-and-validate` | 0.719016 s | 0.7417 s | −3.1% | 1 s budget | in budget |+| `store-level-validation` | 0.728306 s | 0.7404 s | −1.6% | 1 s budget | in budget |+| `recent-coherent` | 0.813241 s | 0.8755 s | −7.1% | 2 s budget | in budget |+| `recent-duplicateSiteRows` | 0.810180 s | 0.8812 s | −8.1% | 2 s budget, ≤ 1.25× ratio | in budget |+| `recent-publication-duplicate-free` | 0.833765 s | 0.8860 s | −5.9% | 2 s budget | in budget |+| `works-snapshot-duplicate-free` | 1.656226 s | 1.6821 s | −1.5% | 3 s class ceiling | in ceiling — see the note below |+| `works-snapshot-series` | 1.563360 s | 1.7667 s | −11.5% | 3 s class ceiling | in ceiling |+| `works-snapshot-creators` | 1.619351 s | 1.7519 s | −7.6% | 3 s class ceiling | in ceiling |+| `record-counts-duplicate-free` | 0.232228 s | 0.2396 s | −3.1% | 3 s class ceiling | in ceiling |+| `creators-list` | 0.267018 s | 0.275745 s | −3.2% | 3 s class ceiling | in ceiling |+| `merge-destinations` | 1.335017 s | 1.3600 s | −1.8% | 3 s class ceiling | in ceiling |+| `backup-projection-duplicate-free` | 1.424994 s | 1.4711 s | −3.1% | reported only | unchanged |+| `membership-reconcile-noop` | 0.397508 s | 0.4047 s | −1.8% | 800 ms ceiling | in ceiling |+| `membership-heal-full` | 1.769584 s | 1.8028 s | −1.8% | 5 s ceiling | in ceiling |+| `reconcile-noop-coherent` | 0.030192 s | 0.03082 s | −2.0% | 10 ms ceiling (known issue) | breached 3.02×, in band |+| `reconcile-noop-arrival` | 0.029190 s | 0.03098 s | −5.8% | — | the tiers still measure the same thing |+| `duplicate-arrival-pass-gated` | 0.027919 s | 0.03072 s | −9.1% | — | as above |+| `duplicate-observation-pass` | 0.970070 s | 1.0675 s | −9.1% | 2 s budget | in budget |+| `duplicate-settling-pass` | 1.628011 s | 1.6998 s | −4.2% | 2 s budget | in budget |+| `reconcile-worst-case-consolidation` | 37.0606 s | 41.347 s | −10.4% | 55 s ceiling | in ceiling |+| `capture-projection-duplicateSiteRows` | 0.164295 s | 0.20485 s | −19.8% | 100 ms budget (known issue), 250 ms ceiling | breached 1.64×, in ceiling |+| `capture-projection-siteMissing` | 0.158088 s | 0.19712 s | −19.8% | as above | breached 1.58×, in ceiling |+| `capture-projection-duplicateIdentity` | 0.163976 s | 0.17888 s | −8.3% | as above | breached 1.64×, in ceiling |+| `capture-rule-application` | 71 µs | 71 µs | — | 100 ms budget | in budget |+| `capture-rule-application-duplicateSiteRows` | 68 µs | 86 µs | −21% | 100 ms budget | in budget |+| `capture-rule-application-siteMissing` | < 1 µs | — | — | 100 ms budget | in budget |+| `capture-rule-application-duplicateIdentity` | 68 µs | 76 µs | −11% | 100 ms budget | in budget |+| `diagnosis-refresh-foreground` | 0.264249 s | 0.29655 s | −10.9% | 250 ms budget (known issue), 400 ms ceiling | breached 1.06×, in ceiling |+| `diagnosis-refresh-after-write` | 0.264120 s | 0.28567 s | −7.5% | as above | breached 1.06×, in ceiling |+| `diagnosis-refresh-duplicateSiteRows` | 0.265298 s | 0.28538 s | −7.0% | as above | breached 1.06×, in ceiling |+| `complete-preview-expanded` | 0.077593 s | 0.08002 s | −3.0% | 1 s budget | in budget |+| `complete-preview-collapsed` | 0.029344 s | 0.03019 s | −2.8% | 1 s budget | in budget |+| `complete-preview-title-matching` | 0.071072 s | 0.07356 s | −3.4% | 1 s budget | in budget |+| `complete-preview-identity-matching` | 0.170110 s | 0.17712 s | −4.0% | 1 s budget | in budget |+| `edit-ack-expanded` / `-collapsed` | 15 µs / 6 µs | 15 µs / 6 µs | — | 100 ms budget | in budget |+| `edit-ack-title-matching` | 582 µs | 559 µs | +4.1% | 100 ms budget | in budget |+| `edit-ack-identity-matching` | 3.399 ms | 3.52 ms | −3.4% | 100 ms budget | in budget |+| `series-resolve-and-group` | 0.003195 s | 0.003348 s | −4.6% | 10 ms budget | in budget, at 32% of it |+| `dedupe-links-fetch` | 0.008284 s | 0.009064 s | −8.6% | reported only | 83% of the phase below |+| `dedupe-links-noop` | 0.010032 s | 0.010991 s | −8.7% | 10 ms budget (known issue), 25 ms ceiling | breached 1.003×, in ceiling |+| `credits-resolve-and-filter` | 0.013437 s | 0.015025 s | −10.6% | 20 ms budget | in budget, at 67% of it |+| `creator-converge-noop` | 0.008071 s | 0.009414 s | −14.3% | 10 ms budget (known issue when it fires, Q74), 20 ms ceiling | **in budget**, recorded nothing |+| `dedupe-credits-fetch` | 0.045027 s | 0.047711 s | −5.6% | reported only | 76% of the phase below |+| `dedupe-credits-noop` | 0.059056 s | 0.062610 s | −5.7% | 50 ms budget (known issue, Q73), 130 ms ceiling | breached 1.18×, in ceiling |+| `creator-detail` | 0.034867 s | 0.036318 s | −4.0% | 50 ms budget | in budget, at 70% of it |+| `character-ranking-200x50` | **0.003681 s** | 0.002461 s | **+49.6%** | 10 ms budget, 50 ms ceiling | in budget — [§2.1](#21-the-character-arm-moved-and-it-is-the-generic-refactor) |+| **`place-ranking-200x50`** | **0.003547 s** | — (new) | — | 10 ms budget, 50 ms ceiling | **in budget**, at 35% of it |++**Nothing was re-banded and no bound was adjusted.** Every arm but+`character-ranking-200x50` moved down, which is the host and not this feature —+V13's two new tables (`Place`, `PlaceSuppression`) are empty in every fixture the+target opens, so a V13 store of the composed fixture is the work a V12 store was,+the same way V12's three tables were for V11.++Two spreads are worth a sentence:++- **`works-snapshot-duplicate-free` spread 2.92×** (median 1.656 s, p95 3.943 s,+  max 4.529 s). One of the twenty samples took nearly three times the median.+  The median is what is asserted — `PerformanceDistribution.assertsTailBudget`+  only asserts the p95 on a run declared `CONTROLLED=1`, and this run was not —+  and every other read arm in the same suite came back with a 1.0–1.1× spread,+  so this is one scheduling hiccup rather than a property of the read. It is+  exactly the shape `CLAUDE.md`'s "not reproducible as they stand" paragraph+  warns about.+- Everything else sat at 1.01–1.22×, which is the tightest full run in this+  file's history.++## 5. What task 27 carries into `CLAUDE.md`++The `make test-performance-m4` paragraph needs four edits and nothing else:++1. **The test count moves 40 → 41**; the suite count stays **7**. One arm was+   added (`place-ranking-200x50`) and no suite was.+2. **The wall time is unchanged** — 1,070 s of test time here against 1,142 s for+   `work-creators` and 1,120.6 s before it, all quiet-host runs, so "~21 minutes"+   still holds and the new arm costs no measurable wall time (it is a 3.5 ms+   measurement × 20 samples).+3. **The known-issue count is unchanged**: **nine** on a quiet host, ten on a+   loaded one, with `creator-converge-noop` still the intermittent tenth. This+   feature adds no known issue and retires none.+4. **The character ranking arm has a new resting place** — 0.0037 s, up from+   0.0023–0.0025 s, the cost of the generic `RecordRanking`; the place arm+   measures 0.0035 s against the same 10 ms budget and 50 ms ceiling. Point at+   this file for both.++Two band updates for `docs/agent-notes/testing.md` if it repeats the numbers:+`dedupe-credits-noop`'s band extends down to **0.0591 s** (was 0.0626–0.0651 s)+and `creator-converge-noop`'s down to **0.0081 s** (was 0.0094–0.0100 s), both+still against the same budgets and the same ceilings.++Req 6.1's on-device timing comparison is **not** in this file: it is task 26's,+and it needs the phone.
specs/retire-migration-chain/library-graph-baseline.txt Modified +11 / -2
diff --git a/specs/retire-migration-chain/library-graph-baseline.txt b/specs/retire-migration-chain/library-graph-baseline.txtindex 8b9dd68..9c18be6 100644--- a/specs/retire-migration-chain/library-graph-baseline.txt+++ b/specs/retire-migration-chain/library-graph-baseline.txt@@ -39,8 +39,17 @@ # with epoch timestamps here. Re-recorded by adding the three counts and # the three section markers by hand and reviewing the diff line by line, not # by regenerating the file.-format 9-counts entries=5 works=1 sites=3 titlePatterns=1 urlRulePatterns=1 workTypes=3 memberships=1 distinctPairs=0 series=0 links=0 creators=0 creatorRoles=3 credits=0+# format 10 is schema V13 (place-extraction, T-2276): the dump gains a+# place and a placeSuppression section and their two counts. No work line+# and no other section changes at all — V13 is the second stage that adds+# only tables — which is the point: the two empty sections are the+# baseline's own statement that the V12 -> V13 stage leaves every existing+# row exactly as it found it. Neither table has a seeded default, so both+# are empty here where the role section is not. Re-recorded by adding the+# two counts and the two section markers by hand and reviewing the diff+# line by line, not by regenerating the file.+format 10+counts entries=5 works=1 sites=3 titlePatterns=1 urlRulePatterns=1 workTypes=3 memberships=1 distinctPairs=0 series=0 links=0 creators=0 creatorRoles=3 credits=0 places=0 placeSuppressions=0 site hostname="alpha.test" displayName="Alpha Reader" modeRaw="untaught" junkSuffixRule=nil site hostname="beta.test" displayName="Beta Serials" modeRaw="taught" junkSuffixRule=nil site hostname="gamma.test" displayName="Gamma Articles" modeRaw="articles" junkSuffixRule="{\"anchors\":[{\"offset\":0,\"origin\":\"end\"}],\"version\":1}"

Things to double-check

The Req 6.1 phone gate is still open.

Task 26 needs a Development install of main and of this branch on the phone with one tap each. The host signal sits on the bar (median 125% of the character-only time against a 25% allowance), so the phone numbers decide. The code side of Req 5.2, publishing Place and PlaceSuppression to the dev container, rides the same run. Until then the branch is reviewed, not released.

A second install on the dev container merges its data in.

Both configurations mirror. A Development build of this branch on a second device joins the shared dev library, and once any device publishes marker "13" a pre-feature build refuses the store by name. Install main first, then this branch, in that order.

The character ranking arm moved by half.

character-ranking-200x50 went from about 2.5 ms to 3.7 ms with the generic ranker, still 37% of budget. Every other arm moved down on a quieter host, so it is the refactor, not variance. Recorded in verification-run.md §2.1; read the next run against 3.7 ms.

Three Codable passes per record on the work-page read.

The group build canonicalises factsData (decode plus encode) and the ranker decodes it again, now over two tables. No arm measures the work-detail read since Q19 dropped its budget. Worth a decision on whether RecordGroup carries decoded facts.

Import opens four whole-table fetches.

mergeImportedRecords and mergeImportedSuppressions each fetch a whole table per kind, matching the Work and Entry steps' shape. Nothing records whether the doubling was weighed against the import chunk budget.

Chunk-scoped place read in the reconciler depends on plan order.

The new per-chunk read is loaded at the chunk's first Entry plan. A Work plan earlier in the chunk can re-point an Entry onto a survivor with another UUID, which moves the works the read is scoped to; the run's Work-before-Entry ordering is what makes that safe, and the struct's comment says so.